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