fix prod crash with yaml parsing
This commit is contained in:
parent
6ca22c5dd0
commit
b709ae9fbe
5 changed files with 234 additions and 83 deletions
42
k8s/traefik-security-headers-middleware.yaml
Normal file
42
k8s/traefik-security-headers-middleware.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: security-headers
|
||||||
|
namespace: towerops
|
||||||
|
spec:
|
||||||
|
headers:
|
||||||
|
# HSTS - Force HTTPS for 1 year, include subdomains, allow preloading
|
||||||
|
# WARNING: Only enable after confirming HTTPS works correctly!
|
||||||
|
# Once set, browsers will REFUSE to connect via HTTP for 1 year
|
||||||
|
customResponseHeaders:
|
||||||
|
Strict-Transport-Security: "max-age=31536000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
# Permissions-Policy - Disable unnecessary browser features
|
||||||
|
# Adjust based on your needs (e.g., if you use geolocation, remove it from the deny list)
|
||||||
|
Permissions-Policy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
||||||
|
|
||||||
|
# X-Permitted-Cross-Domain-Policies - Prevent Adobe Flash/PDF from loading data
|
||||||
|
X-Permitted-Cross-Domain-Policies: "none"
|
||||||
|
---
|
||||||
|
# Example: How to apply this middleware to your IngressRoute
|
||||||
|
# You would add this to your existing IngressRoute in k8s/ingressroute.yaml
|
||||||
|
#
|
||||||
|
# apiVersion: traefik.io/v1alpha1
|
||||||
|
# kind: IngressRoute
|
||||||
|
# metadata:
|
||||||
|
# name: towerops-web
|
||||||
|
# namespace: towerops
|
||||||
|
# spec:
|
||||||
|
# entryPoints:
|
||||||
|
# - websecure
|
||||||
|
# routes:
|
||||||
|
# - kind: Rule
|
||||||
|
# match: Host(`towerops.net`) || Host(`www.towerops.net`)
|
||||||
|
# middlewares:
|
||||||
|
# - name: security-headers # <-- Add this line
|
||||||
|
# services:
|
||||||
|
# - name: towerops-web
|
||||||
|
# port: 4000
|
||||||
|
# tls:
|
||||||
|
# certResolver: letsencrypt
|
||||||
|
|
@ -108,16 +108,34 @@ defmodule Towerops.Profiles.YamlProfiles do
|
||||||
|> Path.wildcard()
|
|> Path.wildcard()
|
||||||
|
|
||||||
# Load profiles and collect raw detection YAMLs
|
# Load profiles and collect raw detection YAMLs
|
||||||
raw_profiles =
|
results =
|
||||||
yaml_files
|
Enum.map(yaml_files, fn file ->
|
||||||
|> Enum.map(fn file ->
|
|
||||||
name = Path.basename(file, ".yaml")
|
name = Path.basename(file, ".yaml")
|
||||||
load_profile(profiles_path, name)
|
{name, file, load_profile(profiles_path, name)}
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
count = length(yaml_files)
|
# Filter successful profiles and log errors
|
||||||
Logger.info("Loaded #{count} device profiles from YAML files")
|
raw_profiles =
|
||||||
|
results
|
||||||
|
|> Enum.filter(fn
|
||||||
|
{name, file, {:error, error}} ->
|
||||||
|
Logger.warning("Failed to load profile #{name} from #{file}: #{inspect(error)}")
|
||||||
|
|
||||||
|
false
|
||||||
|
|
||||||
|
{_name, _file, nil} ->
|
||||||
|
false
|
||||||
|
|
||||||
|
{_name, _file, _profile} ->
|
||||||
|
true
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {_name, _file, profile} -> profile end)
|
||||||
|
|
||||||
|
success_count = length(raw_profiles)
|
||||||
|
total_count = length(yaml_files)
|
||||||
|
|
||||||
|
Logger.info("Loaded #{success_count}/#{total_count} device profiles from YAML files")
|
||||||
|
|
||||||
raw_profiles
|
raw_profiles
|
||||||
else
|
else
|
||||||
Logger.debug("No profiles directory found at #{detection_path}")
|
Logger.debug("No profiles directory found at #{detection_path}")
|
||||||
|
|
|
||||||
|
|
@ -149,38 +149,44 @@ defmodule ToweropsWeb.AccountLive.Activity do
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
defp action_badge_class(action) do
|
defp action_badge_class(action) do
|
||||||
case action do
|
action
|
||||||
"user_data_viewed" ->
|
|> action_category()
|
||||||
"bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20"
|
|> badge_class_for_category()
|
||||||
|
end
|
||||||
|
|
||||||
"user_data_exported" ->
|
defp action_category(action) do
|
||||||
|
cond do
|
||||||
|
action in ["user_data_exported", "device_created"] -> :success
|
||||||
|
action in ["user_profile_updated", "device_updated"] -> :warning
|
||||||
|
action in ["device_deleted", "failed_access_attempt"] -> :danger
|
||||||
|
action in ["impersonate_start", "impersonate_end"] -> :impersonation
|
||||||
|
action == "privilege_escalation" -> :privilege
|
||||||
|
action == "user_data_viewed" -> :info
|
||||||
|
true -> :default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp badge_class_for_category(category) do
|
||||||
|
case category do
|
||||||
|
:success ->
|
||||||
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
||||||
|
|
||||||
"user_profile_updated" ->
|
:warning ->
|
||||||
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
||||||
|
|
||||||
"device_created" ->
|
:danger ->
|
||||||
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
|
||||||
|
|
||||||
"device_updated" ->
|
|
||||||
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
|
||||||
|
|
||||||
"device_deleted" ->
|
|
||||||
"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20"
|
"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20"
|
||||||
|
|
||||||
"failed_access_attempt" ->
|
:impersonation ->
|
||||||
"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20"
|
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20"
|
||||||
|
|
||||||
"privilege_escalation" ->
|
:privilege ->
|
||||||
"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20"
|
"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20"
|
||||||
|
|
||||||
"impersonate_start" ->
|
:info ->
|
||||||
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20"
|
"bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20"
|
||||||
|
|
||||||
"impersonate_end" ->
|
:default ->
|
||||||
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20"
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
"bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
|
"bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -192,62 +198,82 @@ defmodule ToweropsWeb.AccountLive.Activity do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp action_description(log) do
|
defp action_description(log) do
|
||||||
case log.action do
|
description_handlers = %{
|
||||||
"user_data_viewed" ->
|
"user_data_viewed" => &describe_user_data_viewed/1,
|
||||||
if log.superuser do
|
"user_data_exported" => fn _log -> "You exported your account data" end,
|
||||||
"Admin viewed your account data"
|
"user_profile_updated" => fn _log -> "You updated your profile" end,
|
||||||
else
|
"device_created" => &describe_device_created/1,
|
||||||
"You viewed your account data"
|
"device_updated" => &describe_device_updated/1,
|
||||||
end
|
"device_deleted" => &describe_device_deleted/1,
|
||||||
|
"org_data_accessed" => &describe_org_data_accessed/1,
|
||||||
|
"failed_access_attempt" => &describe_failed_access/1,
|
||||||
|
"privilege_escalation" => &describe_privilege_escalation/1,
|
||||||
|
"impersonate_start" => &describe_impersonate_start/1,
|
||||||
|
"impersonate_end" => &describe_impersonate_end/1
|
||||||
|
}
|
||||||
|
|
||||||
"user_data_exported" ->
|
handler = Map.get(description_handlers, log.action)
|
||||||
"You exported your account data"
|
|
||||||
|
|
||||||
"user_profile_updated" ->
|
if handler do
|
||||||
"You updated your profile"
|
handler.(log)
|
||||||
|
else
|
||||||
|
format_action(log.action)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
"device_created" ->
|
defp describe_user_data_viewed(log) do
|
||||||
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
if log.superuser do
|
||||||
"You created device: #{device_name}"
|
"Admin viewed your account data"
|
||||||
|
else
|
||||||
|
"You viewed your account data"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
"device_updated" ->
|
defp describe_device_created(log) do
|
||||||
device_id = get_in(log.metadata, ["device_id"])
|
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
||||||
"You updated device (ID: #{device_id})"
|
"You created device: #{device_name}"
|
||||||
|
end
|
||||||
|
|
||||||
"device_deleted" ->
|
defp describe_device_updated(log) do
|
||||||
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
device_id = get_in(log.metadata, ["device_id"])
|
||||||
"You deleted device: #{device_name}"
|
"You updated device (ID: #{device_id})"
|
||||||
|
end
|
||||||
|
|
||||||
"org_data_accessed" ->
|
defp describe_device_deleted(log) do
|
||||||
org_id = get_in(log.metadata, ["organization_id"])
|
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
||||||
action_detail = get_in(log.metadata, ["action"]) || "accessed"
|
"You deleted device: #{device_name}"
|
||||||
"You #{action_detail} organization data (ID: #{org_id})"
|
end
|
||||||
|
|
||||||
"failed_access_attempt" ->
|
defp describe_org_data_accessed(log) do
|
||||||
resource = get_in(log.metadata, ["resource"]) || "resource"
|
org_id = get_in(log.metadata, ["organization_id"])
|
||||||
"Failed access attempt to: #{resource}"
|
action_detail = get_in(log.metadata, ["action"]) || "accessed"
|
||||||
|
"You #{action_detail} organization data (ID: #{org_id})"
|
||||||
|
end
|
||||||
|
|
||||||
"privilege_escalation" ->
|
defp describe_failed_access(log) do
|
||||||
from_role = get_in(log.metadata, ["from_role"]) || "member"
|
resource = get_in(log.metadata, ["resource"]) || "resource"
|
||||||
to_role = get_in(log.metadata, ["to_role"]) || "admin"
|
"Failed access attempt to: #{resource}"
|
||||||
"Privilege changed from #{from_role} to #{to_role}"
|
end
|
||||||
|
|
||||||
"impersonate_start" ->
|
defp describe_privilege_escalation(log) do
|
||||||
if log.superuser do
|
from_role = get_in(log.metadata, ["from_role"]) || "member"
|
||||||
"Admin #{log.superuser.email} started impersonating you"
|
to_role = get_in(log.metadata, ["to_role"]) || "admin"
|
||||||
else
|
"Privilege changed from #{from_role} to #{to_role}"
|
||||||
"Impersonation started"
|
end
|
||||||
end
|
|
||||||
|
|
||||||
"impersonate_end" ->
|
defp describe_impersonate_start(log) do
|
||||||
if log.superuser do
|
if log.superuser do
|
||||||
"Admin #{log.superuser.email} stopped impersonating you"
|
"Admin #{log.superuser.email} started impersonating you"
|
||||||
else
|
else
|
||||||
"Impersonation ended"
|
"Impersonation started"
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
_ ->
|
defp describe_impersonate_end(log) do
|
||||||
format_action(log.action)
|
if log.superuser do
|
||||||
|
"Admin #{log.superuser.email} stopped impersonating you"
|
||||||
|
else
|
||||||
|
"Impersonation ended"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -427,16 +427,30 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp action_badge_class(action) do
|
defp action_badge_class(action) do
|
||||||
case action do
|
action
|
||||||
"user_data_viewed" -> "bg-blue-50 text-blue-700 ring-blue-600/20"
|
|> audit_action_category()
|
||||||
"user_data_exported" -> "bg-green-50 text-green-700 ring-green-600/20"
|
|> audit_badge_class_for_category()
|
||||||
"device_created" -> "bg-green-50 text-green-700 ring-green-600/20"
|
end
|
||||||
"device_updated" -> "bg-yellow-50 text-yellow-700 ring-yellow-600/20"
|
|
||||||
"device_deleted" -> "bg-red-50 text-red-700 ring-red-600/20"
|
defp audit_action_category(action) do
|
||||||
"failed_access_attempt" -> "bg-red-50 text-red-700 ring-red-600/20"
|
cond do
|
||||||
"impersonate_start" -> "bg-orange-50 text-orange-700 ring-orange-600/20"
|
action in ["user_data_exported", "device_created"] -> :success
|
||||||
"impersonate_end" -> "bg-orange-50 text-orange-700 ring-orange-600/20"
|
action == "device_updated" -> :warning
|
||||||
_ -> "bg-gray-50 text-gray-700 ring-gray-600/20"
|
action in ["device_deleted", "failed_access_attempt"] -> :danger
|
||||||
|
action in ["impersonate_start", "impersonate_end"] -> :impersonation
|
||||||
|
action == "user_data_viewed" -> :info
|
||||||
|
true -> :default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp audit_badge_class_for_category(category) do
|
||||||
|
case category do
|
||||||
|
:success -> "bg-green-50 text-green-700 ring-green-600/20"
|
||||||
|
:warning -> "bg-yellow-50 text-yellow-700 ring-yellow-600/20"
|
||||||
|
:danger -> "bg-red-50 text-red-700 ring-red-600/20"
|
||||||
|
:impersonation -> "bg-orange-50 text-orange-700 ring-orange-600/20"
|
||||||
|
:info -> "bg-blue-50 text-blue-700 ring-blue-600/20"
|
||||||
|
:default -> "bg-gray-50 text-gray-700 ring-gray-600/20"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,57 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "error handling" do
|
||||||
|
test "application survives malformed YAML files in profiles directory" do
|
||||||
|
# This test ensures that malformed YAML files don't crash the GenServer during startup.
|
||||||
|
# The bug: load_profile/2 returns {:error, ...} tuples which end up in the profiles list,
|
||||||
|
# then extract_mib_names_from_profiles/1 tries to do profile["discovery"] on error tuples,
|
||||||
|
# causing FunctionClauseError in Access.get/3.
|
||||||
|
|
||||||
|
# Create a temporary malformed YAML file in the profiles directory
|
||||||
|
profiles_path = Path.join(:code.priv_dir(:towerops), "profiles/os_detection")
|
||||||
|
malformed_file = Path.join(profiles_path, "test_malformed.yaml")
|
||||||
|
|
||||||
|
# Write truly malformed YAML (unclosed bracket)
|
||||||
|
File.write!(malformed_file, "sysObjectID: [\n - 1.2.3.4.5\ntext: bad")
|
||||||
|
|
||||||
|
try do
|
||||||
|
# Verify that YamlProfiles GenServer is still running and functional
|
||||||
|
# despite the malformed YAML file existing
|
||||||
|
assert Process.whereis(YamlProfiles)
|
||||||
|
|
||||||
|
# Verify we can still match profiles (the GenServer didn't crash)
|
||||||
|
system_info = %{
|
||||||
|
sys_object_id: "1.3.6.1.4.1.10002.1",
|
||||||
|
sys_descr: "Linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
client_opts = [
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
community: "public",
|
||||||
|
version: "2c",
|
||||||
|
port: 161
|
||||||
|
]
|
||||||
|
|
||||||
|
stub(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||||
|
{:error, :no_such_object}
|
||||||
|
end)
|
||||||
|
|
||||||
|
stub(SnmpMock, :walk, fn _target, _oid, _opts ->
|
||||||
|
{:error, :no_such_object}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should be able to match profiles without crashing
|
||||||
|
_profile = YamlProfiles.match_profile(system_info, client_opts)
|
||||||
|
|
||||||
|
# The test passes if we get here without crashing
|
||||||
|
after
|
||||||
|
# Cleanup: remove the malformed file
|
||||||
|
File.rm(malformed_file)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "MIB name caching" do
|
describe "MIB name caching" do
|
||||||
test "creates :mib_cache ETS table on startup" do
|
test "creates :mib_cache ETS table on startup" do
|
||||||
# Verify table exists (created by MibCache GenServer)
|
# Verify table exists (created by MibCache GenServer)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue