Current section
Files
Jump to
Current section
Files
lib/scan.ex
defmodule Paraxial.Scan do
@moduledoc false
alias Paraxial.Finding
def get_timestamp() do
DateTime.utc_now()
end
def make_license(license_scan) do
license_scan
|> Enum.map(fn [dep, version, license] ->
%Finding{
source: "license_scan",
content: %{
"dependency" => dep,
"version" => version,
"license" => license,
"reason" => "GPL software is not allowed for this site."
}
}
end)
end
def make_sobelow(raw_scan) do
# Input: The raw json output from a sobelow scan
# Action: Use Jason (it's already in project) to parse, construct findings
# Returns a list of findings
case Jason.decode(raw_scan) do
{:ok, scan_map} ->
sobelow_get_findings(scan_map)
{:error, _e} ->
# Umbrella app
many_scans = String.split(raw_scan, "\n\n") |> tl()
Enum.map(many_scans, fn one_scan ->
string_json = String.split(one_scan, "\n==>") |> hd()
case Jason.decode(string_json) do
{:ok, scan_map} ->
sobelow_get_findings(scan_map)
_ ->
[]
end
end)
|> List.flatten()
end
end
def sobelow_get_findings(scan_map) do
# Input: The sobelow map like %{"findings" => %{"high_confidence" => ...}}
# Output: A list of findings
Enum.map(scan_map["findings"], fn {confidence, findings} ->
Enum.map(findings, fn finding ->
%Finding{
source: "sobelow",
content: Map.put(finding, "confidence", confidence)
}
end)
end)
|> List.flatten()
end
def make_deps(raw_deps, ignore \\ nil) do
raw_deps
|> String.split("\n\n")
|> Enum.map(&dep_to_finding/1)
|> List.flatten()
|> filter_deps(ignore)
|> Enum.filter(fn finding -> severity_exists?(finding) end)
end
# Advisory IDs as printed by hex.audit and deps.audit, e.g.
# CVE-2026-32687, EEF-CVE-2026-43969, GHSA-p8f7-22gq-m7j9
@vuln_id_regex ~r/^(EEF-CVE|CVE|GHSA)-/i
def read_ignore_file() do
if File.exists?("./.paraxial-ignore-deps") do
parse_ignore_lines(File.read!("./.paraxial-ignore-deps"))
else
nil
end
end
# Parses the contents of .paraxial-ignore-deps. Valid lines:
# hackney -> ignore the entire dependency
# postgrex CVE-2026-32687 -> ignore one vulnerability of one dependency
# Blank lines and lines starting with # are skipped. Anything else is
# collected under :malformed so the scan task can warn about it.
def parse_ignore_lines(raw) do
raw
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#")))
|> Enum.reduce(%{deps: [], vulns: [], malformed: []}, fn line, acc ->
case String.split(line, " ", trim: true) do
[dep] ->
if vuln_id?(dep) do
%{acc | malformed: acc.malformed ++ [line]}
else
%{acc | deps: acc.deps ++ [dep]}
end
[dep, vuln] ->
if vuln_id?(vuln) and not vuln_id?(dep) do
%{acc | vulns: acc.vulns ++ [{dep, vuln}]}
else
%{acc | malformed: acc.malformed ++ [line]}
end
_ ->
%{acc | malformed: acc.malformed ++ [line]}
end
end)
end
defp vuln_id?(token), do: Regex.match?(@vuln_id_regex, token)
# Builds ignore entries from hex's ignore_advisories / ignore_retirements
# settings (hex >= 2.5.1), so findings the user acknowledged for hex.audit
# are also not reported by paraxial, including the same advisories found
# via deps.audit and hex versions that predate the settings.
#
# Sources, in hex's order of precedence: the HEX_IGNORE_ADVISORIES /
# HEX_IGNORE_RETIREMENTS environment variables (comma-separated,
# retirements as NAME or NAME@VERSION), then the :hex key of the project
# config. Returns nil when nothing is configured.
def read_hex_ignores() do
hex_config =
case Mix.Project.config()[:hex] do
config when is_list(config) -> config
_ -> []
end
parse_hex_ignores(
hex_config,
System.get_env("HEX_IGNORE_ADVISORIES"),
System.get_env("HEX_IGNORE_RETIREMENTS")
)
end
# Advisory entries are ID strings, matched against a finding's primary ID
# and aka aliases. Retirement entries are {name, version} tuples where a
# nil version matches every version of the package, same as hex. A set env
# var takes precedence over the project config unless it is empty
# (skip_env_if_empty in Hex.State). Entries hex would reject as invalid
# are skipped.
def parse_hex_ignores(hex_config, env_advisories \\ nil, env_retirements \\ nil) do
env_advisory_ids = split_env_list(env_advisories)
advisory_ids =
if env_advisory_ids != [] do
env_advisory_ids
else
hex_config
|> config_list(:ignore_advisories)
|> Enum.filter(&(is_binary(&1) and &1 != ""))
end
env_retirement_entries =
env_retirements |> split_env_list() |> Enum.flat_map(&parse_env_retirement/1)
retirements =
if env_retirement_entries != [] do
env_retirement_entries
else
hex_config
|> config_list(:ignore_retirements)
|> Enum.flat_map(&parse_config_retirement/1)
end
if advisory_ids == [] and retirements == [] do
nil
else
%{advisory_ids: advisory_ids, retirements: retirements}
end
end
defp config_list(config, key) do
case config[key] do
list when is_list(list) -> list
_ -> []
end
end
defp split_env_list(nil), do: []
defp split_env_list(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
# "decimal" or "phoenix@1.0.0"
defp parse_env_retirement(entry) do
case String.split(entry, "@", parts: 2) do
[name] -> [{name, nil}]
[name, version] when name != "" and version != "" -> [{name, version}]
_ -> []
end
end
# :decimal or {:phoenix, "1.0.0"} in mix.exs
defp parse_config_retirement(name) when is_atom(name) and name not in [nil, true, false],
do: [{Atom.to_string(name), nil}]
defp parse_config_retirement({name, version})
when is_atom(name) and name not in [nil, true, false] and is_binary(version),
do: [{Atom.to_string(name), version}]
defp parse_config_retirement(_), do: []
# Combines the .paraxial-ignore-deps ignores with the hex config ignores
# into one map the filter functions accept.
def merge_ignores(nil, nil), do: nil
def merge_ignores(file_ignore, hex_ignore) do
%{deps: [], vulns: [], malformed: [], advisory_ids: [], retirements: []}
|> Map.merge(file_ignore || %{})
|> Map.merge(hex_ignore || %{})
end
def filter_deps(deps_list, nil), do: deps_list
def filter_deps(deps_list, ignore) do
ig = downcase_ignore(ignore)
Enum.reject(deps_list, fn finding ->
name = finding.content["Name"] |> to_string() |> String.trim() |> String.downcase()
cve = finding.content["CVE"] |> to_string() |> String.downcase()
url = finding.content["URL"] |> to_string() |> String.downcase()
name in ig.deps or
Enum.any?(ig.vulns, fn {dep, vuln} ->
dep == name and (vuln == cve or String.contains?(url, vuln))
end) or
Enum.any?(ig.advisory_ids, fn id -> id == cve or String.contains?(url, id) end)
end)
end
def filter_hex(findings, nil), do: findings
def filter_hex(findings, ignore) do
ig = downcase_ignore(ignore)
Enum.reject(findings, fn finding ->
name = finding.content["dependency"] |> to_string() |> String.downcase()
ids =
[finding.content["cve_id"] | Map.get(finding.content, "aka", [])]
|> Enum.reject(&is_nil/1)
|> Enum.map(&String.downcase/1)
name in ig.deps or
Enum.any?(ig.vulns, fn {dep, vuln} -> dep == name and vuln in ids end) or
Enum.any?(ig.advisory_ids, &(&1 in ids)) or
retirement_ignored?(finding, name, ig.retirements)
end)
end
# Mirrors hex's ignore_retirements matching: applies only to retired
# findings (never advisories, even for the same package), a nil version
# matches every version of the package.
defp retirement_ignored?(finding, name, retirements) do
finding.content["hex_type"] != "advisory" and
Enum.any?(retirements, fn
{^name, nil} -> true
{^name, pinned} -> finding.content["version"] == pinned
_ -> false
end)
end
defp downcase_ignore(ignore) do
%{
deps: Enum.map(Map.get(ignore, :deps, []), &String.downcase/1),
vulns:
Enum.map(Map.get(ignore, :vulns, []), fn {d, v} ->
{String.downcase(d), String.downcase(v)}
end),
advisory_ids: Enum.map(Map.get(ignore, :advisory_ids, []), &String.downcase/1),
retirements:
Enum.map(Map.get(ignore, :retirements, []), fn {n, v} -> {String.downcase(n), v} end)
}
end
defp severity_exists?(fmap) do
Enum.any?(Map.keys(fmap.content), fn key ->
String.downcase(key) == "severity"
end)
end
def fpart_to_tuple(fpart) do
fpart
|> String.split(":", parts: 2)
|> List.to_tuple()
end
def dep_to_finding(dep_string) do
finding = String.split(dep_string, "\n", trim: true)
if length(finding) <= 2 do
[]
else
fmap =
finding
|> Enum.map(&fpart_to_tuple/1)
# On some Elixir apps compilation metadata shows up here as tuples of 1
# Filter out tuples that do not equal length 2 to avoid errors
|> Enum.filter(fn tup -> tuple_size(tup) == 2 end)
|> Map.new()
%Finding{
source: "deps.audit",
content: fmap
}
end
end
def print_findings(findings) do
Enum.map(findings, fn finding ->
IO.puts("[Paraxial] #{finding.source}")
Enum.each(Map.to_list(finding.content), fn {label, line} ->
IO.puts(" #{label}: #{line}")
end)
IO.puts("")
end)
end
# Input: Raw output from mix hex.audit
# Returns a list of findings
def make_hex(raw_hex, ignore \\ nil)
def make_hex("No retired packages found\n", _ignore), do: []
def make_hex("No retired packages found\nNo advisories found\n", _ignore), do: []
# Hex >= 2.5.1 all-clear message; may be followed by unused-ignore warnings,
# which Hex prints to stdout.
def make_hex("No retired or security advisory packages found" <> _rest, _ignore), do: []
def make_hex(raw_hex, ignore) do
{active_hex, had_ignored_section} = strip_hex_ignored_sections(raw_hex)
findings =
if had_ignored_section or String.contains?(active_hex, "Retired:\n") or
String.contains?(active_hex, "Advisories:\n") do
make_hex_new(active_hex)
else
make_hex_old(active_hex)
end
filter_hex(findings, ignore)
end
# Hex >= 2.5.1 prints findings acknowledged via the ignore_advisories /
# ignore_retirements mix.exs config in "Ignored retired:" and
# "Ignored advisories:" sections after the active ones, without failing the
# audit. Drop everything from the first ignored section onward so those
# entries are never parsed as findings, and so ignored-only output (which
# has no Retired:/Advisories: header) still routes to the hex >= 2.5 parser.
defp strip_hex_ignored_sections(raw_hex) do
case String.split(raw_hex, ~r/^Ignored (?:retired|advisories):\n/m, parts: 2) do
[active, _ignored] -> {active, true}
[_] -> {raw_hex, false}
end
end
# Parses the old hex.audit format (hex < 2.5.0):
# Dependency Version Retirement reason
# mojito 0.7.12 (deprecated) ...
defp make_hex_old(raw_hex) do
raw_lines = String.split(raw_hex, "\n", trim: true)
raw_findings = tl(raw_lines)
Enum.map(raw_findings, fn finding ->
[dep, ver | reason] = String.split(finding, " ", trim: true)
%Finding{
source: "hex.audit",
content: %{
"dependency" => dep,
"version" => ver,
"reason" => Enum.join(reason, " ")
}
}
end)
end
# Parses the new hex.audit format (hex >= 2.5.0) which has two sections:
#
# Retired:
# dep version - reason text
#
# Advisories:
# dep version - CVE-ID (SEVERITY)
# aka: alias1, alias2
# Description text
# https://url
defp make_hex_new(raw_hex) do
retired = parse_hex_retired_section(raw_hex)
advisories = parse_hex_advisories_section(raw_hex)
retired ++ advisories
end
defp parse_hex_retired_section(raw_hex) do
case Regex.run(~r/Retired:\n(.*?)\n(?:Advisories:|\z)/s, raw_hex, capture: :all_but_first) do
[block] ->
block
|> String.split("\n", trim: true)
|> Enum.reject(&footer_line?/1)
|> Enum.flat_map(fn line ->
# " dep version - reason text". Skip anything else, e.g. the
# unused-ignore warnings hex >= 2.5.1 prints to stdout.
case Regex.run(~r/^(\S+)\s+(\S+)\s+-\s+(.*)$/, String.trim(line)) do
[_, dep, ver, reason] ->
[%Finding{
source: "hex.audit",
content: %{
"dependency" => dep,
"version" => ver,
"reason" => reason,
"hex_type" => "retired"
}
}]
nil ->
[]
end
end)
nil ->
[]
end
end
defp parse_hex_advisories_section(raw_hex) do
case Regex.run(~r/Advisories:\n(.*)/s, raw_hex, capture: :all_but_first) do
[block] ->
block
|> String.split("\n\n", trim: true)
|> Enum.reject(fn chunk -> footer_line?(String.trim(chunk)) end)
|> Enum.flat_map(&parse_hex_advisory_block/1)
nil ->
[]
end
end
defp parse_hex_advisory_block(block) do
lines =
block
|> String.split("\n", trim: true)
|> Enum.reject(&footer_line?/1)
|> Enum.map(&String.trim/1)
case lines do
[header | _rest] ->
# Header: "dep version - CVE-ID" or "dep version - CVE-ID (SEVERITY)"
# Lines are: [header, "aka: ...", "Title text", "https://url"]
title = Enum.at(lines, 2)
case Regex.run(~r/^(\S+)\s+(\S+)\s+-\s+(\S+)(?:\s+\((\w+)\))?$/, header) do
[_, dep, ver, cve_id, severity] ->
[%Finding{
source: "hex.audit",
content: %{
"dependency" => dep,
"version" => ver,
"cve_id" => cve_id,
"severity" => if(severity == "", do: nil, else: severity),
"title" => title,
"url" => find_url_in_lines(lines),
"aka" => find_aka_in_lines(lines),
"hex_type" => "advisory"
}
}]
[_, dep, ver, cve_id] ->
# No (SEVERITY) suffix in header line
[%Finding{
source: "hex.audit",
content: %{
"dependency" => dep,
"version" => ver,
"cve_id" => cve_id,
"severity" => nil,
"title" => title,
"url" => find_url_in_lines(lines),
"aka" => find_aka_in_lines(lines),
"hex_type" => "advisory"
}
}]
_ ->
[]
end
_ ->
[]
end
end
# Parses "aka: CVE-2026-43969, GHSA-g2wm-735q-3f56" into a list of IDs
defp find_aka_in_lines(lines) do
case Enum.find(lines, fn l -> String.starts_with?(l, "aka:") end) do
nil ->
[]
aka_line ->
aka_line
|> String.trim_leading("aka:")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
end
defp find_url_in_lines(lines) do
Enum.find(lines, fn l -> String.starts_with?(l, "http") end)
end
defp footer_line?(line) do
line == "Found retired packages" or
line == "Found packages with security advisories" or
line == "No advisories found" or
line == "No retired packages found"
end
end