diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 72506d0..403977c 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -28,6 +28,8 @@ style: active: false UnusedPrivateMember: active: false + ThrowsCount: + active: false empty-blocks: EmptyFunctionBlock: diff --git a/src/main/kotlin/net/leanix/githubagent/config/GitHubEnterpriseProperties.kt b/src/main/kotlin/net/leanix/githubagent/config/GitHubEnterpriseProperties.kt index 00681ff..bf93f3b 100644 --- a/src/main/kotlin/net/leanix/githubagent/config/GitHubEnterpriseProperties.kt +++ b/src/main/kotlin/net/leanix/githubagent/config/GitHubEnterpriseProperties.kt @@ -8,4 +8,5 @@ data class GitHubEnterpriseProperties( val gitHubAppId: String, val pemFile: String, val manifestFileDirectory: String, + val webhookSecret: String ) diff --git a/src/main/kotlin/net/leanix/githubagent/controllers/GitHubWebhookController.kt b/src/main/kotlin/net/leanix/githubagent/controllers/GitHubWebhookController.kt index ca1c3fe..460d6ca 100644 --- a/src/main/kotlin/net/leanix/githubagent/controllers/GitHubWebhookController.kt +++ b/src/main/kotlin/net/leanix/githubagent/controllers/GitHubWebhookController.kt @@ -1,8 +1,6 @@ package net.leanix.githubagent.controllers -import net.leanix.githubagent.services.WebhookService -import net.leanix.githubagent.shared.SUPPORTED_EVENT_TYPES -import org.slf4j.LoggerFactory +import net.leanix.githubagent.services.GitHubWebhookService import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -13,22 +11,16 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("github") -class GitHubWebhookController(private val webhookService: WebhookService) { - - private val logger = LoggerFactory.getLogger(GitHubWebhookController::class.java) +class GitHubWebhookController(private val gitHubWebhookService: GitHubWebhookService) { @PostMapping("/webhook") @ResponseStatus(HttpStatus.ACCEPTED) fun hook( @RequestHeader("X-Github-Event") eventType: String, + @RequestHeader("X-GitHub-Enterprise-Host") host: String, + @RequestHeader("X-Hub-Signature-256", required = false) signature256: String?, @RequestBody payload: String ) { - runCatching { - if (SUPPORTED_EVENT_TYPES.contains(eventType.uppercase())) { - webhookService.consumeWebhookEvent(eventType, payload) - } else { - logger.warn("Received an unsupported event of type: $eventType") - } - } + gitHubWebhookService.processWebhookEvent(eventType, host, signature256, payload) } } diff --git a/src/main/kotlin/net/leanix/githubagent/controllers/advice/GlobalExceptionHandler.kt b/src/main/kotlin/net/leanix/githubagent/controllers/advice/GlobalExceptionHandler.kt new file mode 100644 index 0000000..83dd6b5 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/controllers/advice/GlobalExceptionHandler.kt @@ -0,0 +1,31 @@ +package net.leanix.githubagent.controllers.advice + +import net.leanix.githubagent.exceptions.InvalidEventSignatureException +import net.leanix.githubagent.exceptions.WebhookSecretNotSetException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + +@ControllerAdvice +class GlobalExceptionHandler { + + val exceptionLogger: Logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(InvalidEventSignatureException::class) + fun handleInvalidEventSignatureException(exception: InvalidEventSignatureException): ProblemDetail { + val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid event signature") + problemDetail.title = exception.message + exceptionLogger.warn(exception.message) + return problemDetail + } + + @ExceptionHandler(WebhookSecretNotSetException::class) + fun handleWebhookSecretNotSetException(exception: WebhookSecretNotSetException): ProblemDetail { + val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Webhook secret not set") + problemDetail.title = exception.message + return problemDetail + } +} diff --git a/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt b/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt index a5aa9b7..04bca83 100644 --- a/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt +++ b/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt @@ -11,3 +11,5 @@ class UnableToConnectToGitHubEnterpriseException(message: String) : RuntimeExcep class JwtTokenNotFound : RuntimeException("JWT token not found") class GraphQLApiException(errors: List) : RuntimeException("Errors: ${errors.joinToString(separator = "\n") { it.message }}") +class WebhookSecretNotSetException : RuntimeException("Webhook secret not set") +class InvalidEventSignatureException : RuntimeException("Invalid event signature") diff --git a/src/main/kotlin/net/leanix/githubagent/services/GitHubWebhookService.kt b/src/main/kotlin/net/leanix/githubagent/services/GitHubWebhookService.kt new file mode 100644 index 0000000..b082933 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/services/GitHubWebhookService.kt @@ -0,0 +1,50 @@ +package net.leanix.githubagent.services + +import net.leanix.githubagent.config.GitHubEnterpriseProperties +import net.leanix.githubagent.exceptions.InvalidEventSignatureException +import net.leanix.githubagent.exceptions.WebhookSecretNotSetException +import net.leanix.githubagent.shared.SUPPORTED_EVENT_TYPES +import net.leanix.githubagent.shared.hmacSHA256 +import net.leanix.githubagent.shared.timingSafeEqual +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class GitHubWebhookService( + private val webhookService: WebhookService, + private val gitHubEnterpriseProperties: GitHubEnterpriseProperties +) { + + private val logger = LoggerFactory.getLogger(GitHubWebhookService::class.java) + private var isWebhookProcessingEnabled = true + + fun processWebhookEvent(eventType: String, host: String, signature256: String?, payload: String) { + if (!isWebhookProcessingEnabled) { + throw WebhookSecretNotSetException() + } + if (!gitHubEnterpriseProperties.baseUrl.contains(host)) { + logger.error("Received a webhook event from an unknown host: $host") + return + } + if (gitHubEnterpriseProperties.webhookSecret == "" && !signature256.isNullOrEmpty()) { + logger.error( + "Event signature is present but webhook secret is not set, " + + "please restart the agent with a valid secret" + ) + isWebhookProcessingEnabled = false + throw WebhookSecretNotSetException() + } + if (gitHubEnterpriseProperties.webhookSecret != "" && !signature256.isNullOrEmpty()) { + val hashedSecret = hmacSHA256(gitHubEnterpriseProperties.webhookSecret, payload) + val isEqual = timingSafeEqual(signature256.removePrefix("sha256="), hashedSecret) + if (!isEqual) throw InvalidEventSignatureException() + } else { + logger.warn("Webhook secret is not set, Skipping signature verification") + } + if (SUPPORTED_EVENT_TYPES.contains(eventType.uppercase())) { + webhookService.consumeWebhookEvent(eventType, payload) + } else { + logger.warn("Received an unsupported event of type: $eventType") + } + } +} diff --git a/src/main/kotlin/net/leanix/githubagent/shared/GitHubWebHookEventHelper.kt b/src/main/kotlin/net/leanix/githubagent/shared/GitHubWebHookEventHelper.kt new file mode 100644 index 0000000..6d03bff --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/shared/GitHubWebHookEventHelper.kt @@ -0,0 +1,24 @@ +package net.leanix.githubagent.shared + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +fun hmacSHA256(secret: String, data: String): String { + val secretKey = SecretKeySpec(secret.toByteArray(), "HmacSHA256") + val mac = Mac.getInstance("HmacSHA256") + mac.init(secretKey) + val hmacData = mac.doFinal(data.toByteArray()) + return hmacData.joinToString("") { "%02x".format(it) } +} + +fun timingSafeEqual(a: String, b: String): Boolean { + val aBytes = a.toByteArray() + val bBytes = b.toByteArray() + if (aBytes.size != bBytes.size) return false + + var diff = 0 + for (i in aBytes.indices) { + diff = diff or (aBytes[i].toInt() xor bBytes[i].toInt()) + } + return diff == 0 +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 105da24..97f5a43 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,6 +3,7 @@ github-enterprise: githubAppId: ${GITHUB_APP_ID:} pemFile: ${PEM_FILE:} manifestFileDirectory: ${MANIFEST_FILE_DIRECTORY:} + webhookSecret: ${WEBHOOK_SECRET:} leanix: base-url: https://${LEANIX_DOMAIN}/services ws-base-url: wss://${LEANIX_DOMAIN}/services/git-integrations/v1/git-ws