From d685db64a12bc175af47dced3dffe4e22ce95d0c Mon Sep 17 00:00:00 2001 From: Chris Rebert Date: Mon, 23 Jan 2017 17:23:07 -0800 Subject: [PATCH 1/2] Bump Java to version 8 --- .travis.yml | 2 ++ SETUP.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 75d0cf3..37870ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,8 @@ sudo: false language: scala scala: - 2.11.8 +jdk: + - oraclejdk8 script: - sbt ++2.11.8 test # avoid unnecessary cache updates diff --git a/SETUP.md b/SETUP.md index 177a46d..eb6f1b5 100644 --- a/SETUP.md +++ b/SETUP.md @@ -16,7 +16,7 @@ If you're want to run Savage in [Docker](https://www.docker.com), you might want 10. Edit `src/main/resources/application.conf` to configure your instance appropriately. See [the README](https://github.com/twbs/savage/blob/master/README.md#usage) for descriptions of all the available settings. 11. Build Savage's all-in-one "assembly" JAR. See ["How do I generate a single self-sufficient JAR that includes all of the necessary dependencies?"](https://github.com/twbs/savage/blob/master/CONTRIBUTING.md#how-do-i-generate-a-single-self-sufficient-jar-that-includes-all-of-the-necessary-dependencies) for instructions on how to do that. 12. Copy the Savage JAR to the location where you'll be running it on your server. -13. Install Java 7+, Git, and OpenSSH in the environment where you will be running Savage. +13. Install Java 8+, Git, and OpenSSH in the environment where you will be running Savage. 14. Create a Unix user account for Savage. Use whatever username you want. 15. As Savage's Unix user, run `ssh-keyscan -t rsa github.com > ~/.ssh/known_hosts` to grab and confirm GitHub's SSH public key. 16. Generate an SSH key for Savage's Unix user. (This will be used to securely pull-from/push-to GitHub.) From 8b093a9ddbdeab128f3fa0e6e427a36d7db7a9a4 Mon Sep 17 00:00:00 2001 From: Chris Rebert Date: Mon, 23 Jan 2017 15:30:10 -0800 Subject: [PATCH 2/2] Implement `Signature:` header verification for Travis This theoretically addresses #43, though ideally we should fetch Travis' public key ourselves rather than requiring the user to copy it into the settings file themself. --- README.md | 19 +++++-- src/main/resources/application.conf | 10 +++- .../crypto/SignatureVerificationStatus.scala | 2 +- .../savage/server/SavageWebService.scala | 2 +- .../getbootstrap/savage/server/Settings.scala | 3 +- .../savage/server/TravisAuthDirectives.scala | 53 ------------------ .../server/TravisSignatureDirectives.scala | 54 +++++++++++++++++++ .../server/TravisWebHookDirectives.scala | 15 ++++-- .../savage/travis/TravisJsonProtocol.scala | 3 +- .../savage/travis/TravisPayload.scala | 11 +++- .../com/getbootstrap/savage/util/Sha256.scala | 22 -------- 11 files changed, 106 insertions(+), 88 deletions(-) delete mode 100644 src/main/scala/com/getbootstrap/savage/server/TravisAuthDirectives.scala create mode 100644 src/main/scala/com/getbootstrap/savage/server/TravisSignatureDirectives.scala delete mode 100644 src/main/scala/com/getbootstrap/savage/util/Sha256.scala diff --git a/README.md b/README.md index dfb5181..9c7fe7f 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,22 @@ savage { // The HMAC is used to verify that Savage is really being contacted by GitHub, // and not by some random hacker. github-web-hook-secret-key = abcdefg - // Used as a shared secret in a hashing scheme that's used to verify - // that Savage is really being contacted by Travis CI, + // Travis's public RSA key. + // Used to verify the signatures of Webhook requests from Travis, + // to ensure that Savage is really being contacted by Travis CI, // and not by some random hacker. For how to find your Travis token, - // see http://docs.travis-ci.com/user/notifications/#Authorization-for-Webhooks - travis-token = abcdefg + // See https://docs.travis-ci.com/user/notifications/#Verifying-Webhook-requests + // If you're using travis-ci.org, then the key is the value of + // config.notifications.webhook.public_key on https://api.travis-ci.org/config + travis-public-key = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvtjdLkS+FP+0fPC09j25 +y/PiuYDDivIT86COVedvlElk99BBYTrqNaJybxjXbIZ1Q6xFNhOY+iTcBr4E1zJu +tizF3Xi0V9tOuP/M8Wn4Y/1lCWbQKlWrNQuqNBmhovF4K3mDCYswVbpgTmp+JQYu +Bm9QMdieZMNry5s6aiMA9aSjDlNyedvSENYo18F+NYg1J0C0JiPYTxheCb4optr1 +5xNzFKhAkuGs4XTOA5C7Q06GCKtDNf44s/CVE30KODUxBi0MCKaxiXw/yy55zxX2 +/YdGphIyQiA5iO1986ZmZCLLW8udz9uhW5jUr3Jlp9LbmphAC61bVSf4ou2YsJaN +0QIDAQAB +-----END PUBLIC KEY-----""" } ``` diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index d6b2f1a..3c3ca6d 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -50,5 +50,13 @@ savage { username = twbs-savage password = XXXXXXXX github-web-hook-secret-key = abcdefg - travis-token = abcdefg + travis-public-key = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvtjdLkS+FP+0fPC09j25 +y/PiuYDDivIT86COVedvlElk99BBYTrqNaJybxjXbIZ1Q6xFNhOY+iTcBr4E1zJu +tizF3Xi0V9tOuP/M8Wn4Y/1lCWbQKlWrNQuqNBmhovF4K3mDCYswVbpgTmp+JQYu +Bm9QMdieZMNry5s6aiMA9aSjDlNyedvSENYo18F+NYg1J0C0JiPYTxheCb4optr1 +5xNzFKhAkuGs4XTOA5C7Q06GCKtDNf44s/CVE30KODUxBi0MCKaxiXw/yy55zxX2 +/YdGphIyQiA5iO1986ZmZCLLW8udz9uhW5jUr3Jlp9LbmphAC61bVSf4ou2YsJaN +0QIDAQAB +-----END PUBLIC KEY-----""" } diff --git a/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala b/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala index 2697335..5fce0d5 100644 --- a/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala +++ b/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala @@ -5,5 +5,5 @@ sealed trait SignatureVerificationStatus object SuccessfullyVerified extends SignatureVerificationStatus trait FailedVerification extends SignatureVerificationStatus -object FailedVerification extends SignatureVerificationStatus +object FailedVerification extends FailedVerification case class ExceptionDuringVerification(error: Throwable) extends FailedVerification diff --git a/src/main/scala/com/getbootstrap/savage/server/SavageWebService.scala b/src/main/scala/com/getbootstrap/savage/server/SavageWebService.scala index e0d9508..a73820c 100644 --- a/src/main/scala/com/getbootstrap/savage/server/SavageWebService.scala +++ b/src/main/scala/com/getbootstrap/savage/server/SavageWebService.scala @@ -69,7 +69,7 @@ class SavageWebService( path("travis") { pathEndOrSingleSlash { post { - authenticatedTravisEvent(travisToken = settings.TravisToken, repo = settings.TestRepoId, log = log) { event => + authenticatedTravisEvent(travisPublicKey = settings.TravisPublicKey, testRepo = settings.TestRepoId, log = log) { event => SavageBranch(event.branchName) match { case Some(branch@SavageBranch(prNum, _)) => { branchDeleter ! branch diff --git a/src/main/scala/com/getbootstrap/savage/server/Settings.scala b/src/main/scala/com/getbootstrap/savage/server/Settings.scala index 2a31f49..0401ca4 100644 --- a/src/main/scala/com/getbootstrap/savage/server/Settings.scala +++ b/src/main/scala/com/getbootstrap/savage/server/Settings.scala @@ -10,6 +10,7 @@ import akka.actor.ExtensionIdProvider import akka.actor.ExtendedActorSystem import akka.util.ByteString import org.eclipse.egit.github.core.RepositoryId +import com.getbootstrap.savage.crypto.RsaPublicKey import com.getbootstrap.savage.github.Branch import com.getbootstrap.savage.util.{FilePathWhitelist,FilePathWatchlist,Utf8String,RichConfig} @@ -19,7 +20,7 @@ class SettingsImpl(config: Config) extends Extension { val BotUsername: String = config.getString("savage.username") val BotPassword: String = config.getString("savage.password") val GitHubWebHookSecretKey: ByteString = ByteString(config.getString("savage.github-web-hook-secret-key").utf8Bytes) - val TravisToken: String = config.getString("savage.travis-token") + val TravisPublicKey: RsaPublicKey = RsaPublicKey.fromPem(config.getString("savage.travis-public-key")).get val UserAgent: String = config.getString("spray.can.client.user-agent-header") val DefaultPort: Int = config.getInt("savage.default-port") val SquelchInvalidHttpLogging: Boolean = config.getBoolean("savage.squelch-invalid-http-logging") diff --git a/src/main/scala/com/getbootstrap/savage/server/TravisAuthDirectives.scala b/src/main/scala/com/getbootstrap/savage/server/TravisAuthDirectives.scala deleted file mode 100644 index a9fd7aa..0000000 --- a/src/main/scala/com/getbootstrap/savage/server/TravisAuthDirectives.scala +++ /dev/null @@ -1,53 +0,0 @@ -package com.getbootstrap.savage.server - -import scala.util.Try -import akka.event.LoggingAdapter -import spray.http.FormData -import spray.routing.{Directive1, MalformedHeaderRejection, MalformedRequestContentRejection, ValidationRejection} -import spray.routing.directives.{BasicDirectives, HeaderDirectives, RouteDirectives, MarshallingDirectives} -import org.eclipse.egit.github.core.RepositoryId -import com.getbootstrap.savage.util.{Sha256,Utf8String} - -trait TravisAuthDirectives { - import BasicDirectives.provide - import HeaderDirectives.headerValueByName - import RouteDirectives.reject - import MarshallingDirectives.{entity, as} - - private val authorization = "Authorization" - private val authorizationHeaderValue = headerValueByName(authorization) - - def travisAuthorization(log: LoggingAdapter): Directive1[Array[Byte]] = authorizationHeaderValue.flatMap { hex => - Try{ javax.xml.bind.DatatypeConverter.parseHexBinary(hex) }.toOption match { - case Some(bytesFromHex) => provide(bytesFromHex) - case None => { - log.error(s"Received Travis request with malformed hex digest in ${authorization} header!") - reject(MalformedHeaderRejection(authorization, "Malformed SHA-256 hex digest")) - } - } - } - - private val formDataEntity = entity(as[FormData]) - - def stringEntityIfTravisAuthValid(travisToken: String, repo: RepositoryId, log: LoggingAdapter): Directive1[String] = travisAuthorization(log).flatMap { hash => - formDataEntity.flatMap { formData => - val plainText = repo.generateId + travisToken - val auth = new Sha256(hash = hash, plainText = plainText.utf8Bytes) - if (auth.isValid) { - formData.fields.toMap.get("payload") match { - case Some(string) => provide(string) - case None => { - log.error("Received Travis request that was missing the `payload` field!") - reject(MalformedRequestContentRejection("Request body form data lacked required `payload` field")) - } - } - } - else { - log.warning("Received Travis request with incorrect hash!") - reject(ValidationRejection("Incorrect SHA-256 hash")) - } - } - } -} - -object TravisAuthDirectives extends TravisAuthDirectives diff --git a/src/main/scala/com/getbootstrap/savage/server/TravisSignatureDirectives.scala b/src/main/scala/com/getbootstrap/savage/server/TravisSignatureDirectives.scala new file mode 100644 index 0000000..e295cc4 --- /dev/null +++ b/src/main/scala/com/getbootstrap/savage/server/TravisSignatureDirectives.scala @@ -0,0 +1,54 @@ +package com.getbootstrap.savage.server + +import java.util.Base64 +import scala.util.Try +import akka.event.LoggingAdapter +import spray.http.FormData +import spray.routing.{Directive1, MalformedHeaderRejection, MalformedRequestContentRejection, ValidationRejection} +import spray.routing.directives.{BasicDirectives, HeaderDirectives, RouteDirectives, MarshallingDirectives} +import com.getbootstrap.savage.crypto.{RsaPublicKey, Sha1WithRsa, SuccessfullyVerified} +import com.getbootstrap.savage.util.Utf8String + +trait TravisSignatureDirectives { + import BasicDirectives.provide + import HeaderDirectives.headerValueByName + import RouteDirectives.reject + import MarshallingDirectives.{entity, as} + + private val signatureHeaderName = "Signature" + private val signatureHeaderValue = headerValueByName(signatureHeaderName) + + def travisSignature(log: LoggingAdapter): Directive1[Array[Byte]] = signatureHeaderValue.flatMap { base64 => + Try{ Base64.getDecoder.decode(base64) }.toOption match { + case Some(bytesFromBase64) => provide(bytesFromBase64) + case None => { + log.error(s"Received Travis request with malformed Base64 value in ${signatureHeaderName} header!") + reject(MalformedHeaderRejection(signatureHeaderName, "Malformed Base64 value")) + } + } + } + + private val formDataEntity = entity(as[FormData]) + + def stringEntityIfTravisSignatureValid(travisPublicKey: RsaPublicKey, log: LoggingAdapter): Directive1[String] = travisSignature(log).flatMap { signature => + formDataEntity.flatMap { formData => + formData.fields.toMap.get("payload") match { + case Some(payload:String) => { + Sha1WithRsa.verifySignature(signature = signature, publicKey = travisPublicKey, signedData = payload.utf8Bytes) match { + case SuccessfullyVerified => provide(payload) + case _ => { + log.warning("Received Travis request with incorrect signature! Signature={} Payload={}", signature, payload) + reject(ValidationRejection("Incorrect SHA-1+RSA signature")) + } + } + } + case None => { + log.error("Received Travis request that was missing the `payload` field!") + reject(MalformedRequestContentRejection("Request body form data lacked required `payload` field")) + } + } + } + } +} + +object TravisSignatureDirectives extends TravisSignatureDirectives diff --git a/src/main/scala/com/getbootstrap/savage/server/TravisWebHookDirectives.scala b/src/main/scala/com/getbootstrap/savage/server/TravisWebHookDirectives.scala index b793a4f..e6a8863 100644 --- a/src/main/scala/com/getbootstrap/savage/server/TravisWebHookDirectives.scala +++ b/src/main/scala/com/getbootstrap/savage/server/TravisWebHookDirectives.scala @@ -6,21 +6,30 @@ import spray.routing.{Directive1, ValidationRejection} import spray.routing.directives.{BasicDirectives, RouteDirectives} import spray.json._ import org.eclipse.egit.github.core.RepositoryId +import com.getbootstrap.savage.crypto.RsaPublicKey import com.getbootstrap.savage.travis.{TravisJsonProtocol, TravisPayload} trait TravisWebHookDirectives { import RouteDirectives.reject import BasicDirectives.provide - import TravisAuthDirectives.stringEntityIfTravisAuthValid + import TravisSignatureDirectives.stringEntityIfTravisSignatureValid import TravisJsonProtocol._ - def authenticatedTravisEvent(travisToken: String, repo: RepositoryId, log: LoggingAdapter): Directive1[TravisPayload] = stringEntityIfTravisAuthValid(travisToken, repo, log).flatMap{ entityJsonString => + def authenticatedTravisEvent(travisPublicKey: RsaPublicKey, testRepo: RepositoryId, log: LoggingAdapter): Directive1[TravisPayload] = stringEntityIfTravisSignatureValid(travisPublicKey, log).flatMap{ entityJsonString => Try { entityJsonString.parseJson.convertTo[TravisPayload] } match { case Failure(exc) => { log.error("Received Travis request with bad JSON!") reject(ValidationRejection("JSON was either malformed or did not match expected schema!")) } - case Success(payload) => provide(payload) + case Success(payload) => { + val TestRepo = testRepo + payload.repository.id match { + case TestRepo => provide(payload) + case otherRepo => { + reject(ValidationRejection(s"Received Travis request regarding irrelevant repo ${otherRepo}")) + } + } + } } } } diff --git a/src/main/scala/com/getbootstrap/savage/travis/TravisJsonProtocol.scala b/src/main/scala/com/getbootstrap/savage/travis/TravisJsonProtocol.scala index 05bd43e..7510c70 100644 --- a/src/main/scala/com/getbootstrap/savage/travis/TravisJsonProtocol.scala +++ b/src/main/scala/com/getbootstrap/savage/travis/TravisJsonProtocol.scala @@ -3,5 +3,6 @@ package com.getbootstrap.savage.travis import spray.json._ object TravisJsonProtocol extends DefaultJsonProtocol { - implicit val travisPayloadFormat = jsonFormat4(TravisPayload.apply) + implicit val travisRepositoryFormat = jsonFormat2(Repository.apply) + implicit val travisPayloadFormat = jsonFormat5(TravisPayload.apply) } diff --git a/src/main/scala/com/getbootstrap/savage/travis/TravisPayload.scala b/src/main/scala/com/getbootstrap/savage/travis/TravisPayload.scala index 4d7a1ea..433e6da 100644 --- a/src/main/scala/com/getbootstrap/savage/travis/TravisPayload.scala +++ b/src/main/scala/com/getbootstrap/savage/travis/TravisPayload.scala @@ -2,14 +2,23 @@ package com.getbootstrap.savage.travis import scala.util.{Try,Success,Failure} import spray.http.Uri +import org.eclipse.egit.github.core.RepositoryId import com.getbootstrap.savage.github.{Branch, CommitSha} import com.getbootstrap.savage.travis.build_status.BuildStatus +case class Repository( + owner_name: String, + name: String +) { + def id: RepositoryId = RepositoryId.create(owner_name, name) +} + case class TravisPayload( status_message: String, build_url: String, branch: String, - commit: String + commit: String, + repository: Repository ) { def status: BuildStatus = BuildStatus(status_message).getOrElse{ throw new IllegalStateException(s"Invalid Travis build status message: ${status_message}") } def commitSha: CommitSha = CommitSha(commit).getOrElse{ throw new IllegalStateException(s"Invalid commit SHA: ${commit}") } diff --git a/src/main/scala/com/getbootstrap/savage/util/Sha256.scala b/src/main/scala/com/getbootstrap/savage/util/Sha256.scala deleted file mode 100644 index 4028e34..0000000 --- a/src/main/scala/com/getbootstrap/savage/util/Sha256.scala +++ /dev/null @@ -1,22 +0,0 @@ -package com.getbootstrap.savage.util - -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException - -object Sha256 { - private val Sha256Algorithm = "SHA-256" -} - -case class Sha256(hash: Array[Byte], plainText: Array[Byte]) { - import Sha256.Sha256Algorithm - - @throws[NoSuchAlgorithmException]("if SHA-256 is not supported") - private lazy val correct: Array[Byte] = { - MessageDigest.getInstance(Sha256Algorithm).digest(plainText) - } - - lazy val isValid: Boolean = MessageDigest.isEqual(hash, correct) - - def givenHex = hash.asHexBytes - def correctHex = correct.asHexBytes -}