diff --git a/.travis.yml b/.travis.yml index 6155f19..75d0cf3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ sudo: false language: scala scala: - - 2.11.5 + - 2.11.8 script: - - sbt ++2.11.5 test + - sbt ++2.11.8 test # avoid unnecessary cache updates - find $HOME/.sbt -name "*.lock" | xargs rm - find $HOME/.ivy2 -name "ivydata-*.properties" | xargs rm diff --git a/LICENSE.txt b/LICENSE.txt index be6d760..00401c8 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Chris Rebert +Copyright (c) 2014-2016 The Savage Authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4342246..dfb5181 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ savage { // pushing the branch to GitHub, Savage will assume something went wrong and delete the branch to // keep the test repo's branches tidy. travis-timeout = 2 hours + // Include a link to the "preview URL" of the PR in the GitHub comment? + // Probably only makes sense if you're Bootstrap. + // Otherwise, you'll need to edit the hardcoded URL template string. + show-preview-urls = false // Full name of GitHub repo to watch for new pull requests github-repo-to-watch = "twbs/bootstrap" // Full name of GitHub repo to push test branches to github-test-repo = "twbs/bootstrap-tests" - // Ignore pull requests whose branch is from the watched repo (and is thus from a project team member) - ignore-branches-from-watched-repo = true // Pull requests must target one of these branches in the watched repo allowed-base-branches = [ "master" ] // List of GitHub organization names whose public members Savage should trust to authorize retries of builds diff --git a/SECURITY.md b/SECURITY.md index 4f05753..7e15b42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ ## DISCLAIMER -The current author are not security experts and this project has not been subjected to a third-party security audit. +The current author(s) are not security experts and this project has not been subjected to a third-party security audit. + +USE AT YOUR OWN RISK. Caveat emptor. ## Responsible disclosure; Security contact info @@ -68,7 +70,7 @@ Out of scope per our assumptions: * Compromise of GitHub * Compromise of Travis CI API * Compromise of the machine on which Savage resides -* Compromise of out outbound communications with GitHub +* Compromise of our outbound communications with GitHub * Allowing modification of a sensitive file due to incorrect whitelist settings Within scope: diff --git a/assembly.sbt b/assembly.sbt deleted file mode 100644 index 473f907..0000000 --- a/assembly.sbt +++ /dev/null @@ -1,4 +0,0 @@ -import AssemblyKeys._ - -assemblySettings - diff --git a/build.sbt b/build.sbt index d1cc7f5..fecc1a8 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ name := "savage" version := "1.0" -scalaVersion := "2.11.5" +scalaVersion := "2.11.8" mainClass := Some("com.getbootstrap.savage.server.Boot") @@ -12,21 +12,24 @@ resolvers += "Eclipse Foundation Releases" at "https://repo.eclipse.org/content/ resolvers += "Eclipse Foundation Snapshots" at "https://repo.eclipse.org/content/repositories/snapshots/" -libraryDependencies += "org.eclipse.mylyn.github" % "org.eclipse.egit.github.core" % "4.0.0.201503231230-m1" +libraryDependencies += "org.eclipse.mylyn.github" % "org.eclipse.egit.github.core" % "4.6.0.201612231935-r" // egit-github needs Gson, but doesn't explicitly require it -libraryDependencies += "com.google.code.gson" % "gson" % "2.3.1" +libraryDependencies += "com.google.code.gson" % "gson" % "2.8.0" -libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.1.2" +libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.1.9" + +// For reading PEM ("-----BEGIN PUBLIC KEY-----"), which Travis's API uses for its public key. +libraryDependencies += "org.bouncycastle" % "bcpkix-jdk15on" % "1.56" libraryDependencies ++= { - val akkaV = "2.3.9" - val sprayV = "1.3.2" + val akkaV = "2.3.16" + val sprayV = "1.3.4" Seq( "io.spray" %% "spray-can" % sprayV, "io.spray" %% "spray-routing" % sprayV, "io.spray" %% "spray-testkit" % sprayV % "test", - "io.spray" %% "spray-json" % "1.3.1", + "io.spray" %% "spray-json" % "1.3.3", "com.typesafe.akka" %% "akka-actor" % akkaV, "com.typesafe.akka" %% "akka-slf4j" % akkaV, "com.typesafe.akka" %% "akka-testkit" % akkaV % "test", diff --git a/project/build.properties b/project/build.properties index 817bc38..27e88aa 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.9 +sbt.version=0.13.13 diff --git a/project/plugins.sbt b/project/plugins.sbt index 49c4f3e..d7757b8 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,3 @@ -addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.8.0") -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.11.2") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3") diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 9af89ec..d6b2f1a 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -26,9 +26,9 @@ savage { squelch-invalid-http-logging = true set-commit-status = true travis-timeout = 2 hours + show-preview-urls = true github-repo-to-watch = "twbs/bootstrap" github-test-repo = "twbs-savage/bootstrap" - ignore-branches-from-watched-repo = true allowed-base-branches = [ "master", "v4-dev", "v3-dev" ] trusted-orgs = [ "twbs" ] whitelist = [ diff --git a/src/main/scala/com/getbootstrap/savage/util/HmacSha1.scala b/src/main/scala/com/getbootstrap/savage/crypto/HmacSha1.scala similarity index 91% rename from src/main/scala/com/getbootstrap/savage/util/HmacSha1.scala rename to src/main/scala/com/getbootstrap/savage/crypto/HmacSha1.scala index 93340ac..2af880b 100644 --- a/src/main/scala/com/getbootstrap/savage/util/HmacSha1.scala +++ b/src/main/scala/com/getbootstrap/savage/crypto/HmacSha1.scala @@ -1,9 +1,10 @@ -package com.getbootstrap.savage.util +package com.getbootstrap.savage.crypto import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import java.security.{NoSuchAlgorithmException, InvalidKeyException, SignatureException} import java.security.MessageDigest +import com.getbootstrap.savage.util.HexByteArray object HmacSha1 { private val HmacSha1Algorithm = "HmacSHA1" diff --git a/src/main/scala/com/getbootstrap/savage/crypto/Pem.scala b/src/main/scala/com/getbootstrap/savage/crypto/Pem.scala new file mode 100644 index 0000000..47496f1 --- /dev/null +++ b/src/main/scala/com/getbootstrap/savage/crypto/Pem.scala @@ -0,0 +1,41 @@ +package com.getbootstrap.savage.crypto + +import scala.util.{Try,Success,Failure} +import java.io.StringReader +import java.security.spec.X509EncodedKeySpec +import org.bouncycastle.util.io.pem.PemReader +import org.bouncycastle.util.io.pem.PemObject + + +sealed class MalformedPemException(cause: Throwable) extends RuntimeException("The given data did not conform to the PEM format!", cause) + +sealed class UnexpectedPemDataTypeException(expectedType: String, pemObj: PemObject) + extends RuntimeException(s"PEM contained data of unexpected type! Expected: ${expectedType} Actual: ${pemObj.getType}") + +// PEM is the name for the format that involves "-----BEGIN PUBLIC KEY-----" etc. +object Pem { + private val PublicKeyPemType = "PUBLIC KEY" + + @throws[MalformedPemException]("if there is a problem decoding the PEM data") + private def decode(pem: String): PemObject = { + val pemReader = new PemReader(new StringReader(pem)) + val pemObjTry = Try { pemReader.readPemObject() } + val closeTry = Try { pemReader.close() } + (pemObjTry, closeTry) match { + case (Failure(readExc), _) => throw new MalformedPemException(readExc) + case (_, Failure(closeExc)) => throw new MalformedPemException(closeExc) + case (Success(pemObj), Success(_)) => pemObj + } + } + + // Decodes PKCS8 data in PEM format into a X509EncodedKeySpec + // which can be handled by sun.security.rsa.RSAKeyFactory + @throws[UnexpectedPemDataTypeException]("if the PEM contains non-public-key data") + def decodePublicKeyIntoSpec(publicKeyInPem: String): X509EncodedKeySpec = { + val pemObj = decode(publicKeyInPem) + pemObj.getType match { + case PublicKeyPemType => new X509EncodedKeySpec(pemObj.getContent) + case unexpectedType => throw new UnexpectedPemDataTypeException(PublicKeyPemType, pemObj) + } + } +} diff --git a/src/main/scala/com/getbootstrap/savage/crypto/RsaPublicKey.scala b/src/main/scala/com/getbootstrap/savage/crypto/RsaPublicKey.scala new file mode 100644 index 0000000..a91c6ed --- /dev/null +++ b/src/main/scala/com/getbootstrap/savage/crypto/RsaPublicKey.scala @@ -0,0 +1,16 @@ +package com.getbootstrap.savage.crypto + +import scala.util.Try +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec + + +sealed case class RsaPublicKey private(publicKey: PublicKey) + +object RsaPublicKey { + private val rsaKeyFactory = KeyFactory.getInstance("RSA") // Supported in all spec-compliant JVMs + + def fromX509Spec(keySpec: X509EncodedKeySpec): Try[RsaPublicKey] = Try{ rsaKeyFactory.generatePublic(keySpec) }.map{ new RsaPublicKey(_) } + def fromPem(pem: String): Try[RsaPublicKey] = Try{ Pem.decodePublicKeyIntoSpec(pem) }.flatMap{ fromX509Spec(_) } +} diff --git a/src/main/scala/com/getbootstrap/savage/crypto/Sha1WithRsa.scala b/src/main/scala/com/getbootstrap/savage/crypto/Sha1WithRsa.scala new file mode 100644 index 0000000..c01afee --- /dev/null +++ b/src/main/scala/com/getbootstrap/savage/crypto/Sha1WithRsa.scala @@ -0,0 +1,27 @@ +package com.getbootstrap.savage.crypto + +import java.security.Signature +import java.security.SignatureException +import java.security.InvalidKeyException + + +object Sha1WithRsa { + private val signatureAlgorithmName = "SHA1withRSA" // Supported in all spec-compliant JVMs + private def newSignatureVerifier(): Signature = Signature.getInstance(signatureAlgorithmName) + + def verifySignature(signature: Array[Byte], publicKey: RsaPublicKey, signedData: Array[Byte]): SignatureVerificationStatus = { + val verifier = newSignatureVerifier() + try { + verifier.initVerify(publicKey.publicKey) + verifier.update(signedData) + verifier.verify(signature) match { + case true => SuccessfullyVerified + case false => FailedVerification + } + } + catch { + case keyExc:InvalidKeyException => ExceptionDuringVerification(keyExc) + case sigExc:SignatureException => ExceptionDuringVerification(sigExc) + } + } +} diff --git a/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala b/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala new file mode 100644 index 0000000..2697335 --- /dev/null +++ b/src/main/scala/com/getbootstrap/savage/crypto/SignatureVerificationStatus.scala @@ -0,0 +1,9 @@ +package com.getbootstrap.savage.crypto + +sealed trait SignatureVerificationStatus + +object SuccessfullyVerified extends SignatureVerificationStatus + +trait FailedVerification extends SignatureVerificationStatus +object FailedVerification extends SignatureVerificationStatus +case class ExceptionDuringVerification(error: Throwable) extends FailedVerification diff --git a/src/main/scala/com/getbootstrap/savage/github/commit_status/Status.scala b/src/main/scala/com/getbootstrap/savage/github/commit_status/Status.scala index e3b2d5d..0adc795 100644 --- a/src/main/scala/com/getbootstrap/savage/github/commit_status/Status.scala +++ b/src/main/scala/com/getbootstrap/savage/github/commit_status/Status.scala @@ -3,7 +3,7 @@ package com.getbootstrap.savage.github.commit_status import org.eclipse.egit.github.core.{CommitStatus => RawCommitStatus} object Status { - private val context = "continuous-integration/savage" + private val context = "savage" } trait Status { def description: String diff --git a/src/main/scala/com/getbootstrap/savage/github/pr_action/PullRequestAction.scala b/src/main/scala/com/getbootstrap/savage/github/pr_action/PullRequestAction.scala index dc00ca0..9e2968b 100644 --- a/src/main/scala/com/getbootstrap/savage/github/pr_action/PullRequestAction.scala +++ b/src/main/scala/com/getbootstrap/savage/github/pr_action/PullRequestAction.scala @@ -1,17 +1,18 @@ package com.getbootstrap.savage.github.pr_action object PullRequestAction { - def apply(name: String): Option[PullRequestAction] = { + def apply(name: String): PullRequestAction = { name match { - case Assigned.Name => Some(Assigned) - case Unassigned.Name => Some(Unassigned) - case Labeled.Name => Some(Labeled) - case Unlabeled.Name => Some(Unlabeled) - case Opened.Name => Some(Opened) - case Closed.Name => Some(Closed) - case Reopened.Name => Some(Reopened) - case Synchronize.Name => Some(Synchronize) - case _ => None + case Assigned.Name => Assigned + case Unassigned.Name => Unassigned + case Labeled.Name => Labeled + case Unlabeled.Name => Unlabeled + case Opened.Name => Opened + case Closed.Name => Closed + case Reopened.Name => Reopened + case Synchronize.Name => Synchronize + case Edited.Name => Edited + case _ => Unknown(name) } } } @@ -42,3 +43,7 @@ object Reopened extends PullRequestAction { object Synchronize extends PullRequestAction { override val Name = "synchronize" } +object Edited extends PullRequestAction { + override val Name = "edited" +} +case class Unknown(Name: String) extends PullRequestAction diff --git a/src/main/scala/com/getbootstrap/savage/github/util/package.scala b/src/main/scala/com/getbootstrap/savage/github/util/package.scala index 15ffe0c..2852e0e 100644 --- a/src/main/scala/com/getbootstrap/savage/github/util/package.scala +++ b/src/main/scala/com/getbootstrap/savage/github/util/package.scala @@ -33,7 +33,7 @@ package object util { def asPullRemote: String = s"https://github.com/${repoId.generateId}.git" } implicit class RichPullRequestPayload(val payload: PullRequestPayload) extends AnyVal { - def action: PullRequestAction = PullRequestAction(payload.getAction).get + def action: PullRequestAction = PullRequestAction(payload.getAction) } implicit class RichUser(val user: User) extends AnyVal { def username: GitHubUser = GitHubUser(user.getLogin) diff --git a/src/main/scala/com/getbootstrap/savage/server/HubSignatureDirectives.scala b/src/main/scala/com/getbootstrap/savage/server/HubSignatureDirectives.scala index a0d4153..0308757 100644 --- a/src/main/scala/com/getbootstrap/savage/server/HubSignatureDirectives.scala +++ b/src/main/scala/com/getbootstrap/savage/server/HubSignatureDirectives.scala @@ -3,7 +3,8 @@ package com.getbootstrap.savage.server import scala.util.{Try,Success,Failure} import spray.routing.{Directive1, MalformedHeaderRejection, MalformedRequestContentRejection, ValidationRejection} import spray.routing.directives.{BasicDirectives, HeaderDirectives, RouteDirectives, MarshallingDirectives} -import com.getbootstrap.savage.util.{HmacSha1,Utf8ByteArray} +import com.getbootstrap.savage.crypto.HmacSha1 +import com.getbootstrap.savage.util.Utf8ByteArray trait HubSignatureDirectives { import BasicDirectives.provide diff --git a/src/main/scala/com/getbootstrap/savage/server/PullRequestCommenter.scala b/src/main/scala/com/getbootstrap/savage/server/PullRequestCommenter.scala index b31e08f..c7ee038 100644 --- a/src/main/scala/com/getbootstrap/savage/server/PullRequestCommenter.scala +++ b/src/main/scala/com/getbootstrap/savage/server/PullRequestCommenter.scala @@ -15,12 +15,14 @@ class PullRequestCommenter extends GitHubActorWithLogging { case PullRequestBuildResult(prNum, commitSha, buildUrl, succeeded) => { val mythicalStatus = if (succeeded) { "**CONFIRMED**" } else { "**BUSTED**" } val plainStatus = if (succeeded) { "**Tests passed.**" } else { "**Tests failed.**" } + val previewInfo = if (settings.ShowPreviewUrls) { s"Docs preview: http://preview.twbsapps.com/c/${commitSha.sha}" } else { "" } val commentMarkdown = s""" |${plainStatus} Automated cross-browser testing via Sauce Labs and Travis CI shows that the JavaScript changes in this pull request are: ${mythicalStatus} | |Commit: ${commitSha.sha} |Build details: ${buildUrl} + |${previewInfo} | |(*Please note that this is a [fully automated](https://github.com/twbs/savage) comment.*) """.stripMargin diff --git a/src/main/scala/com/getbootstrap/savage/server/PullRequestEventHandler.scala b/src/main/scala/com/getbootstrap/savage/server/PullRequestEventHandler.scala index aa207b9..c09d596 100644 --- a/src/main/scala/com/getbootstrap/savage/server/PullRequestEventHandler.scala +++ b/src/main/scala/com/getbootstrap/savage/server/PullRequestEventHandler.scala @@ -96,7 +96,6 @@ class PullRequestEventHandler( if (settings.AllowedBaseBranches.contains(destBranch)) { prHead.getRepo.repositoryId match { case None => log.error(s"Received event from GitHub about repository with unsafe name") - case Some(settings.MainRepoId) if settings.IgnoreBranchesFromMainRepo => log.info("Ignoring PR whose branch is from the main repo, per settings.") case Some(foreignRepo) => { val baseSha = bsBase.commitSha val headSha = prHead.commitSha diff --git a/src/main/scala/com/getbootstrap/savage/server/Settings.scala b/src/main/scala/com/getbootstrap/savage/server/Settings.scala index a5e2020..2a31f49 100644 --- a/src/main/scala/com/getbootstrap/savage/server/Settings.scala +++ b/src/main/scala/com/getbootstrap/savage/server/Settings.scala @@ -26,11 +26,11 @@ class SettingsImpl(config: Config) extends Extension { val Whitelist: FilePathWhitelist = new FilePathWhitelist(config.getStringList("savage.whitelist").asScala) val Watchlist: FilePathWatchlist = new FilePathWatchlist(config.getStringList("savage.file-watchlist").asScala) val BranchPrefix: String = config.getString("savage.branch-prefix") - val IgnoreBranchesFromMainRepo: Boolean = config.getBoolean("savage.ignore-branches-from-watched-repo") val AllowedBaseBranches: Set[Branch] = config.getStringList("savage.allowed-base-branches").asScala.flatMap{ Branch(_) }.toSet val TrustedOrganizations: Set[String] = config.getStringList("savage.trusted-orgs").asScala.toSet val SetCommitStatus: Boolean = config.getBoolean("savage.set-commit-status") val TravisTimeout: FiniteDuration = config.getFiniteDuration("savage.travis-timeout") + val ShowPreviewUrls: Boolean = config.getBoolean("savage.show-preview-urls") } object Settings extends ExtensionId[SettingsImpl] with ExtensionIdProvider { override def lookup() = Settings