diff --git a/config/config.exs b/config/config.exs index a03ee106..e45dc559 100644 --- a/config/config.exs +++ b/config/config.exs @@ -54,7 +54,20 @@ config :mime, :types, %{ } # Filter sensitive parameters from logs -config :phoenix, :filter_parameters, ["password", "snmp_community", "secret", "token", "api_key"] +# These parameters will be replaced with "[FILTERED]" in Phoenix logs and error reports +config :phoenix, :filter_parameters, [ + "password", + "snmp_community", + "community", + "secret", + "token", + "api_key", + # SNMPv3 credentials (for future use) + "auth_password", + "priv_password", + "auth_pass", + "priv_pass" +] # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason diff --git a/lib/towerops/honeybadger_filter.ex b/lib/towerops/honeybadger_filter.ex index b7eb559c..3585cc60 100644 --- a/lib/towerops/honeybadger_filter.ex +++ b/lib/towerops/honeybadger_filter.ex @@ -1,6 +1,7 @@ defmodule Towerops.HoneybadgerFilter do @moduledoc """ - Custom Honeybadger filter to exclude noisy errors during deployments. + Custom Honeybadger filter to exclude noisy errors during deployments + and filter sensitive SNMP credentials from error reports. Filters out OTP supervisor/gen_server errors that occur during normal node shutdown/restart. @@ -15,6 +16,20 @@ defmodule Towerops.HoneybadgerFilter do "Elixir.PollerSupervisor" ] + # Sensitive keys that should be filtered from error reports + @sensitive_keys [ + "password", + "snmp_community", + "community", + "secret", + "token", + "api_key", + "auth_password", + "priv_password", + "auth_pass", + "priv_pass" + ] + @impl Honeybadger.Filter def filter_context(context) do cond do @@ -27,10 +42,25 @@ defmodule Towerops.HoneybadgerFilter do nil true -> - super(context) + context + |> super() + |> filter_sensitive_data() end end + @impl Honeybadger.Filter + def filter_params(params) when is_map(params) do + Map.new(params, fn {key, value} -> + if should_filter?(key) do + {key, "[FILTERED]"} + else + {key, filter_params(value)} + end + end) + end + + def filter_params(params), do: params + # Detect anonymous gen_server termination during shutdown defp otp_gen_server_shutdown?(context) do domain = get_in(context, [:domain]) @@ -38,4 +68,29 @@ defmodule Towerops.HoneybadgerFilter do domain == ["otp"] and match?(["gen_server", "error_info", _], mfa) end + + # Filter sensitive data from context + defp filter_sensitive_data(nil), do: nil + + defp filter_sensitive_data(context) when is_map(context) do + context + |> Map.update(:cgi_data, %{}, &filter_params/1) + |> Map.update(:params, %{}, &filter_params/1) + |> Map.update(:session, %{}, &filter_params/1) + |> Map.update(:context, %{}, &filter_params/1) + end + + defp filter_sensitive_data(context), do: context + + # Check if a key should be filtered + defp should_filter?(key) when is_binary(key) do + key_lower = String.downcase(key) + Enum.any?(@sensitive_keys, &String.contains?(key_lower, &1)) + end + + defp should_filter?(key) when is_atom(key) do + should_filter?(Atom.to_string(key)) + end + + defp should_filter?(_), do: false end diff --git a/lib/towerops_web/controllers/user_registration_html/new.html.heex b/lib/towerops_web/controllers/user_registration_html/new.html.heex index 0aaedd5d..43d19642 100644 --- a/lib/towerops_web/controllers/user_registration_html/new.html.heex +++ b/lib/towerops_web/controllers/user_registration_html/new.html.heex @@ -65,6 +65,17 @@ required /> + <%= unless @invitation do %> + <.input + name="user[organization_name]" + value="" + type="text" + label="Organization Name" + placeholder="My Company" + required + /> + <% end %> + <.button phx-disable-with="Creating account..." class="w-full" variant="primary"> {if @invitation, do: "Accept invitation and create account", else: "Create an account"} diff --git a/test/towerops/honeybadger_filter_test.exs b/test/towerops/honeybadger_filter_test.exs new file mode 100644 index 00000000..10aa2639 --- /dev/null +++ b/test/towerops/honeybadger_filter_test.exs @@ -0,0 +1,159 @@ +defmodule Towerops.HoneybadgerFilterTest do + use ExUnit.Case, async: true + + alias Towerops.HoneybadgerFilter + + describe "filter_params/1" do + test "filters SNMP community strings" do + params = %{ + "name" => "Device 1", + "snmp_community" => "public", + "ip_address" => "192.168.1.1" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["name"] == "Device 1" + assert filtered["snmp_community"] == "[FILTERED]" + assert filtered["ip_address"] == "192.168.1.1" + end + + test "filters community strings with just 'community' key" do + params = %{"community" => "secret123", "device_id" => "abc"} + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["community"] == "[FILTERED]" + assert filtered["device_id"] == "abc" + end + + test "filters passwords" do + params = %{"email" => "user@example.com", "password" => "secret"} + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["email"] == "user@example.com" + assert filtered["password"] == "[FILTERED]" + end + + test "filters SNMPv3 credentials" do + params = %{ + "snmp_version" => "3", + "auth_password" => "authpass", + "priv_password" => "privpass", + "username" => "admin" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["snmp_version"] == "3" + assert filtered["auth_password"] == "[FILTERED]" + assert filtered["priv_password"] == "[FILTERED]" + assert filtered["username"] == "admin" + end + + test "filters API keys and tokens" do + params = %{ + "api_key" => "key123", + "token" => "token456", + "secret" => "secret789" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["api_key"] == "[FILTERED]" + assert filtered["token"] == "[FILTERED]" + assert filtered["secret"] == "[FILTERED]" + end + + test "filters nested params" do + params = %{ + "device" => %{ + "name" => "Router", + "snmp_community" => "private", + "ip_address" => "10.0.0.1" + } + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["device"]["name"] == "Router" + assert filtered["device"]["snmp_community"] == "[FILTERED]" + assert filtered["device"]["ip_address"] == "10.0.0.1" + end + + test "filters atom keys" do + params = %{ + name: "Device", + snmp_community: "public", + ip_address: "192.168.1.1" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered[:name] == "Device" + assert filtered[:snmp_community] == "[FILTERED]" + assert filtered[:ip_address] == "192.168.1.1" + end + + test "handles case-insensitive filtering" do + params = %{ + "SNMP_COMMUNITY" => "public", + "SnmpCommunity" => "private", + "API_KEY" => "secret" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered["SNMP_COMMUNITY"] == "[FILTERED]" + assert filtered["SnmpCommunity"] == "[FILTERED]" + assert filtered["API_KEY"] == "[FILTERED]" + end + + test "does not filter non-sensitive data" do + params = %{ + "name" => "Device", + "ip_address" => "192.168.1.1", + "description" => "Main router" + } + + filtered = HoneybadgerFilter.filter_params(params) + + assert filtered == params + end + end + + describe "filter_context/1" do + test "filters sensitive data from context params" do + context = %{ + params: %{ + "device" => %{"snmp_community" => "public", "name" => "Router"} + } + } + + filtered = HoneybadgerFilter.filter_context(context) + + assert filtered.params["device"]["name"] == "Router" + assert filtered.params["device"]["snmp_community"] == "[FILTERED]" + end + + test "filters sensitive data from cgi_data" do + context = %{ + cgi_data: %{"auth_token" => "Bearer token123", "user_agent" => "Mozilla/5.0"} + } + + filtered = HoneybadgerFilter.filter_context(context) + + assert filtered.cgi_data["auth_token"] == "[FILTERED]" + assert filtered.cgi_data["user_agent"] == "Mozilla/5.0" + end + + test "filters ignored registered names" do + context = %{registered_name: "memsup"} + + filtered = HoneybadgerFilter.filter_context(context) + + assert filtered == nil + end + end +end diff --git a/test/towerops_web/controllers/user_registration_controller_test.exs b/test/towerops_web/controllers/user_registration_controller_test.exs index 7ecc8082..13642b3b 100644 --- a/test/towerops_web/controllers/user_registration_controller_test.exs +++ b/test/towerops_web/controllers/user_registration_controller_test.exs @@ -57,5 +57,26 @@ defmodule ToweropsWeb.UserRegistrationControllerTest do assert response =~ "Register" assert response =~ "can't be blank" end + + test "creates organization with provided name", %{conn: conn} do + email = unique_user_email() + + conn = + post(conn, ~p"/users/register", %{ + "user" => %{ + "email" => email, + "password" => valid_user_password(), + "organization_name" => "Acme Corp" + } + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/devices" + + # Verify organization was created with the provided name + user = Towerops.Accounts.get_user_by_email(email) + [org | _] = Towerops.Organizations.list_user_organizations(user.id) + assert org.name == "Acme Corp" + end end end diff --git a/test/towerops_web/controllers/user_registration_html_test.exs b/test/towerops_web/controllers/user_registration_html_test.exs index d0b449ec..cb38eaa7 100644 --- a/test/towerops_web/controllers/user_registration_html_test.exs +++ b/test/towerops_web/controllers/user_registration_html_test.exs @@ -16,7 +16,9 @@ defmodule ToweropsWeb.UserRegistrationHTMLTest do render_to_string(ToweropsWeb.UserRegistrationHTML, "new", "html", flash: %{}, current_scope: nil, - form: form + form: form, + invitation: nil, + invitation_token: nil ) assert html =~ "Register for an account" @@ -24,6 +26,7 @@ defmodule ToweropsWeb.UserRegistrationHTMLTest do assert html =~ "Log in" assert html =~ "Create an account" assert html =~ "Password" + assert html =~ "Organization Name" end end end