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