Current section

Files

Jump to
paraxial lib scan.ex
Raw

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)
def filter_deps(deps_list, nil), do: deps_list
def filter_deps(deps_list, ignore) do
{deps, vulns} = 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 deps or
Enum.any?(vulns, fn {dep, vuln} ->
dep == name and (vuln == cve or String.contains?(url, vuln))
end)
end)
end
def filter_hex(findings, nil), do: findings
def filter_hex(findings, ignore) do
{deps, vulns} = 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 deps or
Enum.any?(vulns, fn {dep, vuln} -> dep == name and vuln in ids end)
end)
end
defp downcase_ignore(ignore) do
deps = Enum.map(ignore.deps, &String.downcase/1)
vulns = Enum.map(ignore.vulns, fn {d, v} -> {String.downcase(d), String.downcase(v)} end)
{deps, vulns}
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: []
def make_hex(raw_hex, ignore) do
findings =
if String.contains?(raw_hex, "Retired:\n") or String.contains?(raw_hex, "Advisories:\n") do
make_hex_new(raw_hex)
else
make_hex_old(raw_hex)
end
filter_hex(findings, ignore)
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.map(fn line ->
# " dep version - reason text"
[dep, ver | rest] = String.split(String.trim(line), " ", trim: true)
reason = rest |> Enum.drop_while(&(&1 == "-")) |> Enum.join(" ")
%Finding{
source: "hex.audit",
content: %{
"dependency" => dep,
"version" => ver,
"reason" => reason,
"hex_type" => "retired"
}
}
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