From ad4fde9415c87cbfc57713182c8aad688e29b52b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Mar 2026 13:53:28 -0500 Subject: [PATCH] fix: humanize raw error strings in integration sync modules Replace binary passthrough in humanize_sync_error/1 across all 6 sync modules (Gaiia, VISP, NetBox, Preseem, Splynx, Sonar) so that raw Erlang/SSL error messages never surface in the UI. SSL/CA cert errors get a clean message; all other raw strings are logged and replaced with a generic user-facing message. --- lib/towerops/gaiia/sync.ex | 9 ++++++++- lib/towerops/netbox/sync.ex | 9 ++++++++- lib/towerops/preseem/sync.ex | 10 +++++++++- lib/towerops/sonar/sync.ex | 9 ++++++++- lib/towerops/splynx/sync.ex | 10 +++++++++- lib/towerops/visp/sync.ex | 10 +++++++++- test/towerops/gaiia/sync_test.exs | 26 ++++++++++++++++++++++++++ test/towerops/visp/sync_test.exs | 3 +-- 8 files changed, 78 insertions(+), 8 deletions(-) diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 71eb4ebd..767e8812 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -214,7 +214,14 @@ defmodule Towerops.Gaiia.Sync do "Connection error — unable to establish a secure connection to Gaiia" end - defp humanize_sync_error(reason) when is_binary(reason), do: reason + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1))) diff --git a/lib/towerops/netbox/sync.ex b/lib/towerops/netbox/sync.ex index dcdacb67..8b7981ce 100644 --- a/lib/towerops/netbox/sync.ex +++ b/lib/towerops/netbox/sync.ex @@ -240,7 +240,14 @@ defmodule Towerops.NetBox.Sync do "Connection error — unable to establish a secure connection to NetBox" end - defp humanize_sync_error(reason) when is_binary(reason), do: reason + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end defp humanize_sync_error({:unexpected_status, status}), do: "NetBox returned unexpected HTTP #{status}" diff --git a/lib/towerops/preseem/sync.ex b/lib/towerops/preseem/sync.ex index 67c25b75..04482306 100644 --- a/lib/towerops/preseem/sync.ex +++ b/lib/towerops/preseem/sync.ex @@ -174,6 +174,14 @@ defmodule Towerops.Preseem.Sync do defp humanize_sync_error(:timeout), do: "Connection timed out — Preseem may be temporarily unavailable" - defp humanize_sync_error(reason) when is_binary(reason), do: reason + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end + defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync" end diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex index 78498777..c28855d0 100644 --- a/lib/towerops/sonar/sync.ex +++ b/lib/towerops/sonar/sync.ex @@ -74,7 +74,14 @@ defmodule Towerops.Sonar.Sync do "GraphQL errors: #{messages}" end - defp humanize_sync_error(reason) when is_binary(reason), do: reason + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end defp humanize_sync_error(reason) do Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}") diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex index 25fd70b0..f12f07fb 100644 --- a/lib/towerops/splynx/sync.ex +++ b/lib/towerops/splynx/sync.ex @@ -67,7 +67,15 @@ defmodule Towerops.Splynx.Sync do defp humanize_sync_error(:forbidden), do: "Access denied — your API credentials may not have sufficient permissions" defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Splynx — retry after #{retry_after}s" defp humanize_sync_error({:unexpected_status, status}), do: "Splynx returned unexpected HTTP #{status}" - defp humanize_sync_error(reason) when is_binary(reason), do: reason + + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end defp humanize_sync_error(reason) do Logger.warning("Splynx sync failed with unexpected error: #{inspect(reason)}") diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex index b8c2cc9b..3aa9b304 100644 --- a/lib/towerops/visp/sync.ex +++ b/lib/towerops/visp/sync.ex @@ -70,7 +70,15 @@ defmodule Towerops.Visp.Sync do defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions" defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by VISP — retry after #{retry_after}s" defp humanize_sync_error({:unexpected_status, status}), do: "VISP returned unexpected HTTP #{status}" - defp humanize_sync_error(reason) when is_binary(reason), do: reason + + defp humanize_sync_error(reason) when is_binary(reason) do + if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do + "SSL certificate verification failed — unable to establish a secure connection" + else + Logger.warning("Sync failed with raw error: #{reason}") + "An unexpected error occurred during sync" + end + end defp humanize_sync_error(reason) do Logger.warning("VISP sync failed with unexpected error: #{inspect(reason)}") diff --git a/test/towerops/gaiia/sync_test.exs b/test/towerops/gaiia/sync_test.exs index 48f5e027..c5d0c73c 100644 --- a/test/towerops/gaiia/sync_test.exs +++ b/test/towerops/gaiia/sync_test.exs @@ -100,6 +100,32 @@ defmodule Towerops.Gaiia.SyncTest do assert updated.last_sync_status == "failed" end + test "humanizes SSL/CA certificate errors in sync message", %{integration: integration} do + Req.Test.stub(Client, fn _conn -> + raise "default CA trust store not available; please add `:castore` to your project's dependencies" + end) + + assert {:error, _} = Sync.sync_organization(integration) + + updated = Repo.reload!(integration) + assert updated.last_sync_status == "failed" + + assert updated.last_sync_message == + "SSL certificate verification failed — unable to establish a secure connection" + end + + test "does not leak raw error strings in sync message", %{integration: integration} do + Req.Test.stub(Client, fn _conn -> + raise "some internal server stacktrace details" + end) + + assert {:error, _} = Sync.sync_organization(integration) + + updated = Repo.reload!(integration) + assert updated.last_sync_status == "failed" + assert updated.last_sync_message == "An unexpected error occurred during sync" + end + test "handles API that returns null when after variable is null", %{ org: _org, integration: integration diff --git a/test/towerops/visp/sync_test.exs b/test/towerops/visp/sync_test.exs index f76906d5..6a3c573b 100644 --- a/test/towerops/visp/sync_test.exs +++ b/test/towerops/visp/sync_test.exs @@ -283,8 +283,7 @@ defmodule Towerops.Visp.SyncTest do updated_integration = Integrations.get_integration!(org.id, "visp") assert updated_integration.last_sync_status == "failed" - # Error message should contain the exception details - assert updated_integration.last_sync_message =~ "Connection refused" + assert updated_integration.last_sync_message == "An unexpected error occurred during sync" end end end