Module: AgentAdmit::Webhook

Defined in:
lib/agentadmit/webhook.rb

Overview

Verification for inbound AgentAdmit alert webhooks.

AgentAdmit signs every alert webhook delivery with the app’s webhook signing secret (whsec_…, returned once when the webhook URL is configured). The signature arrives in the X-AgentAdmit-Signature header:

X-AgentAdmit-Signature: t=<unix_ts>,v1=<hex hmac-sha256>

where the HMAC input is “t.raw_body” keyed with the full whsec_ secret. Always verify against the raw request body (request.raw_post in Rails), before any JSON parsing.

Examples:

Rails controller

def alerts
  AgentAdmit::Webhook.verify_signature(
    request.raw_post,
    request.headers["X-AgentAdmit-Signature"].to_s,
    AgentAdmit.configuration.webhook_secret
  )
  event = JSON.parse(request.raw_post)
  # ...
rescue AgentAdmit::WebhookSignatureError
  head :bad_request
end

Constant Summary collapse

SIGNATURE_HEADER =

Header AgentAdmit signs alert webhook deliveries with.

"X-AgentAdmit-Signature"
DEFAULT_TOLERANCE_SECONDS =

Default maximum clock skew (seconds) allowed for replay protection.

300

Class Method Summary collapse

Class Method Details

.secure_compare(expected, candidate) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Constant-time string comparison (portable — OpenSSL.secure_compare is not available on every openssl gem version).



102
103
104
105
106
107
108
109
# File 'lib/agentadmit/webhook.rb', line 102

def secure_compare(expected, candidate)
  return false unless expected.bytesize == candidate.bytesize

  diff = 0
  expected_bytes = expected.unpack("C*")
  candidate.each_byte.with_index { |byte, i| diff |= byte ^ expected_bytes[i] }
  diff.zero?
end

.valid_signature?(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Boolean

Boolean form of verify_signature.

Returns:

  • (Boolean)


89
90
91
92
93
94
# File 'lib/agentadmit/webhook.rb', line 89

def valid_signature?(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
  verify_signature(payload, header, secret, tolerance: tolerance, now: now)
  true
rescue WebhookSignatureError
  false
end

.verify_signature(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ void

This method returns an undefined value.

Verify the X-AgentAdmit-Signature header on an inbound alert webhook.

Parameters:

  • payload (String)

    the raw request body

  • header (String)

    the X-AgentAdmit-Signature header value

  • secret (String)

    the app’s webhook signing secret (whsec_…)

  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    max clock skew in seconds (0 disables the check)

  • now (Integer, nil) (defaults to: nil)

    override the current Unix timestamp (for tests)

Raises:

  • (WebhookSignatureError)

    if the header is missing/malformed, the timestamp is outside the tolerance window, or no signature matches; the message never includes the secret or the payload



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/agentadmit/webhook.rb', line 54

def verify_signature(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
  raise WebhookSignatureError, "Webhook signing secret is required" if secret.nil? || secret.empty?
  raise WebhookSignatureError, "Missing X-AgentAdmit-Signature header" if header.nil? || header.empty?

  timestamp = nil
  candidates = []
  header.split(",").each do |part|
    key, _, value = part.strip.partition("=")
    case key
    when "t"
      raise WebhookSignatureError, "Malformed signature header" unless value.match?(/\A\d+\z/)

      timestamp = Integer(value)
    when "v1"
      candidates << value
    end
  end

  raise WebhookSignatureError, "Malformed signature header" if timestamp.nil? || candidates.empty?

  if tolerance.positive? && ((now || Time.now.to_i) - timestamp).abs > tolerance
    raise WebhookSignatureError, "Signature timestamp outside tolerance window"
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
  matched = candidates.any? { |candidate| secure_compare(expected, candidate) }

  raise WebhookSignatureError, "Webhook signature verification failed" unless matched
end