towerops/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex
Graham McIntie 34fe5d7e49 Security fixes: mask credentials in logs/API, fix cookie/CSP/LIKE injection/webhooks
CRITICAL fixes:
- Mask SNMP community string in agent channel logs (CRITICAL-1)
- Remove snmpv3 passwords from REST API responses, return _set booleans (CRITICAL-2)
- Replace snmp_community with snmp_community_set in GraphQL type (CRITICAL-3)

HIGH fixes:
- Fix cookie same_site from invalid 'Towerops' to 'Lax' (HIGH-4)
- Remove unsafe-eval from CSP script-src (HIGH-6)
- Block GraphQL introspection queries in production (HIGH-7)
- Sanitize LIKE wildcards in SNMP device name search (HIGH-8)
- Reject webhooks when no secret configured instead of accepting (HIGH-9)

MEDIUM fixes:
- Hash mobile session tokens (SHA-256) before DB storage (MEDIUM-10)
- Apply security headers in all environments, not just prod (MEDIUM-14)
- Add GraphQL query complexity limit (500) in production (MEDIUM-16)
- Fix X-Frame-Options to DENY to match frame-ancestors 'none' (MEDIUM-13)
2026-02-15 09:09:04 -06:00

162 lines
5 KiB
Elixir

defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
@moduledoc """
Handles inbound PagerDuty V3 webhook events.
When an incident is resolved or acknowledged in PagerDuty, this controller
updates the corresponding TowerOps alert so we don't re-alert on it.
PagerDuty signs webhooks with HMAC-SHA256 using a per-integration secret.
The signature is in the `X-PagerDuty-Signature` header as `v1=<hex_digest>`.
"""
use ToweropsWeb, :controller
alias Towerops.Alerts
alias Towerops.Integrations
require Logger
def create(conn, %{"organization_id" => organization_id} = params) do
with {:ok, raw_body} <- get_raw_body(conn),
{:ok, integration} <- get_pagerduty_integration(organization_id),
:ok <- verify_signature(conn, raw_body, integration) do
# params already parsed by Phoenix JSON parser
process_webhook(params, organization_id)
json(conn, %{status: "accepted"})
else
{:error, :not_configured} ->
conn |> put_status(404) |> json(%{error: "Integration not found"})
{:error, :invalid_signature} ->
conn |> put_status(401) |> json(%{error: "Invalid signature"})
{:error, :no_secret_configured} ->
conn |> put_status(403) |> json(%{error: "Webhook secret not configured"})
{:error, _reason} ->
conn |> put_status(500) |> json(%{error: "Internal error"})
end
end
defp get_raw_body(conn) do
case conn.private[:raw_body] do
body when is_binary(body) and body != "" -> {:ok, body}
_ -> {:error, :no_body}
end
end
defp get_pagerduty_integration(organization_id) do
case Integrations.get_integration(organization_id, "pagerduty") do
{:ok, %{enabled: true} = integration} -> {:ok, integration}
_ -> {:error, :not_configured}
end
end
defp verify_signature(conn, raw_body, integration) do
webhook_secret = get_in(integration.credentials, ["webhook_secret"])
if is_nil(webhook_secret) or webhook_secret == "" do
Logger.warning("PagerDuty webhook: rejected - no webhook secret configured")
{:error, :no_secret_configured}
else
check_pagerduty_hmac(conn, raw_body, webhook_secret, integration.organization_id)
end
end
defp check_pagerduty_hmac(conn, raw_body, secret, org_id) do
signatures =
conn
|> Plug.Conn.get_req_header("x-pagerduty-signature")
|> Enum.flat_map(&String.split(&1, ","))
|> Enum.map(&String.trim/1)
expected =
:hmac
|> :crypto.mac(:sha256, secret, raw_body)
|> Base.encode16(case: :lower)
if signature_matches?(signatures, expected) do
:ok
else
Logger.warning("PagerDuty webhook signature mismatch for org #{org_id}")
{:error, :invalid_signature}
end
end
defp signature_matches?(signatures, expected) do
Enum.any?(signatures, fn
"v1=" <> hex -> Plug.Crypto.secure_compare(hex, expected)
_ -> false
end)
end
defp process_webhook(%{"event" => %{"event_type" => event_type, "data" => data}}, organization_id) do
case event_type do
"incident.resolved" ->
resolve_from_pagerduty(data, organization_id)
"incident.acknowledged" ->
acknowledge_from_pagerduty(data, organization_id)
other ->
Logger.debug("Ignoring PagerDuty webhook event: #{other}")
:ok
end
end
defp process_webhook(_payload, _organization_id), do: :ok
defp resolve_from_pagerduty(data, _organization_id) do
case extract_alert_id(data) do
nil ->
Logger.debug("PagerDuty resolve: no TowerOps alert ID found in incident")
:ok
alert_id ->
case Alerts.get_alert(alert_id) do
nil ->
Logger.warning("PagerDuty resolve: alert #{alert_id} not found")
%{resolved_at: resolved_at} when not is_nil(resolved_at) ->
Logger.debug("PagerDuty resolve: alert #{alert_id} already resolved")
alert ->
Logger.info("Resolving alert #{alert_id} from PagerDuty")
Alerts.resolve_alert_silent(alert)
end
end
end
defp acknowledge_from_pagerduty(data, _organization_id) do
case extract_alert_id(data) do
nil ->
:ok
alert_id ->
case Alerts.get_alert(alert_id) do
nil ->
Logger.warning("PagerDuty acknowledge: alert #{alert_id} not found")
%{acknowledged_at: ack} when not is_nil(ack) ->
Logger.debug("PagerDuty acknowledge: alert #{alert_id} already acknowledged")
alert ->
Logger.info("Acknowledging alert #{alert_id} from PagerDuty")
Alerts.acknowledge_alert_silent(alert)
end
end
end
# PagerDuty includes our dedup_key as the alert key.
# Our dedup_key format is "towerops-alert-<uuid>"
defp extract_alert_id(data) do
# Try the first alert's dedup key
alerts = data["alerts"] || []
Enum.find_value(alerts, fn alert ->
case alert["alert_key"] do
"towerops-alert-" <> id -> id
_ -> nil
end
end)
end
end