diff --git a/app/Filters.scala b/app/Filters.scala index 092927c..a65ee2f 100644 --- a/app/Filters.scala +++ b/app/Filters.scala @@ -9,7 +9,7 @@ import play.twirl.api.Txt import scala.concurrent.Future -class HostnameValidatingAction(allowedHostnames: Set[String], allowAllIps: Boolean, next: EssentialAction) extends EssentialAction with Results{ +class HostValidatingAction(allowedHosts: Set[String], allowAllIps: Boolean, next: EssentialAction) extends EssentialAction with Results{ private val IpAddressPatternComponent = // comes from http://www.mkyong.com/regular-expressions/how-to-validate-ip-address-with-regular-expression/ "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + @@ -20,17 +20,17 @@ class HostnameValidatingAction(allowedHostnames: Set[String], allowAllIps: Boole private val IpAddress = ("""^"""+IpAddressPatternComponent+"""((:[0-9]+)?)$""").r override def apply(request: RequestHeader): Iteratee[Array[Byte], Result] = { - if( (allowedHostnames contains request.host) || (allowAllIps && IpAddress.findFirstMatchIn(request.host).isDefined )) next.apply(request) + if( (allowedHosts contains request.host) || (allowAllIps && IpAddress.findFirstMatchIn(request.host).isDefined )) next.apply(request) else Iteratee.flatten(Future.successful(Done(Unauthorized(Txt(s"not allowed for host ${request.host}"))))) } } -class HostnameFilter(allowedHostnames: Set[String], allowAllIps: Boolean = false) extends EssentialFilter { - override def apply(next: EssentialAction): EssentialAction = new HostnameValidatingAction(allowedHostnames, allowAllIps, next) +class HostFilter(allowedHosts: Set[String], allowAllIps: Boolean = false) extends EssentialFilter { + override def apply(next: EssentialAction): EssentialAction = new HostValidatingAction(allowedHosts, allowAllIps, next) } class Filters @Inject() (csrfFilter: CSRFFilter, configuration: Configuration) extends HttpFilters { - def filters = Seq(csrfFilter, new HostnameFilter(configuration.getString("app.hostname").toSet, allowAllIps = true)) + def filters = Seq(csrfFilter, new HostFilter(configuration.getString("app.host").toSet, allowAllIps = true)) } \ No newline at end of file diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 4c050f2..621764d 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -113,4 +113,8 @@ h3.library-identification{ .help{ border-left: 5px solid #00c7ff; padding-left: 10px; +} + +.projects-watching .watched{ + font-weight: bold; } \ No newline at end of file diff --git a/app/com/ysoft/odc/Absolutizer.scala b/app/com/ysoft/odc/Absolutizer.scala new file mode 100644 index 0000000..ae99929 --- /dev/null +++ b/app/com/ysoft/odc/Absolutizer.scala @@ -0,0 +1,7 @@ +package com.ysoft.odc + +import play.api.mvc.Call + +class Absolutizer(host: String, secure: Boolean){ + def absolutize(call: Call) = call.absoluteURL(secure, host) +} \ No newline at end of file diff --git a/app/com/ysoft/odc/AtlassianAuthentication.scala b/app/com/ysoft/odc/AtlassianAuthentication.scala new file mode 100644 index 0000000..148a8b3 --- /dev/null +++ b/app/com/ysoft/odc/AtlassianAuthentication.scala @@ -0,0 +1,15 @@ +package com.ysoft.odc + +import play.api.libs.ws.{WSAuthScheme, WSRequest} + +trait AtlassianAuthentication{ + def addAuth(request: WSRequest): WSRequest +} + +class SessionIdAtlassianAuthentication(sessionId: String) extends AtlassianAuthentication{ + override def addAuth(request: WSRequest): WSRequest = request.withHeaders("Cookie" -> s"JSESSIONID=${sessionId.takeWhile(_.isLetterOrDigit)}") +} + +class CredentialsAtlassianAuthentication(user: String, password: String) extends AtlassianAuthentication{ + override def addAuth(request: WSRequest): WSRequest = request.withQueryString("os_authType" -> "basic").withAuth(user, password, WSAuthScheme.BASIC) +} diff --git a/app/com/ysoft/odc/BambooDownloader.scala b/app/com/ysoft/odc/BambooDownloader.scala index b360c28..5618685 100644 --- a/app/com/ysoft/odc/BambooDownloader.scala +++ b/app/com/ysoft/odc/BambooDownloader.scala @@ -61,19 +61,8 @@ final case class ArtifactDirectory(name: String, items: Map[String, ArtifactItem } final case class FlatArtifactDirectory(name: String, items: Seq[(String, String)]) extends FlatArtifactItem{} -trait BambooAuthentication{ - def addAuth(request: WSRequest): WSRequest -} -class SessionIdBambooAuthentication(sessionId: String) extends BambooAuthentication{ - override def addAuth(request: WSRequest): WSRequest = request.withHeaders("Cookie" -> s"JSESSIONID=${sessionId.takeWhile(_.isLetterOrDigit)}") -} - -class CredentialsBambooAuthentication(user: String, password: String) extends BambooAuthentication{ - override def addAuth(request: WSRequest): WSRequest = request.withQueryString("os_authType" -> "basic").withAuth(user, password, WSAuthScheme.BASIC) -} - -final class BambooDownloader @Inject() (@Named("bamboo-server-url") val server: String, auth: BambooAuthentication)(implicit executionContext: ExecutionContext, wSClient: WSClient) extends Downloader { +final class BambooDownloader @Inject()(@Named("bamboo-server-url") val server: String, @Named("bamboo-authentication") auth: AtlassianAuthentication)(implicit executionContext: ExecutionContext, wSClient: WSClient) extends Downloader { private object ArtifactKeys{ val BuildLog = "Build log" diff --git a/app/com/ysoft/odc/SetDiff.scala b/app/com/ysoft/odc/SetDiff.scala new file mode 100644 index 0000000..8b7c2d2 --- /dev/null +++ b/app/com/ysoft/odc/SetDiff.scala @@ -0,0 +1,8 @@ +package com.ysoft.odc + +class SetDiff[T](val oldSet: Set[T], val newSet: Set[T]) { + lazy val added = newSet -- oldSet + lazy val removed = oldSet -- newSet + lazy val isEmpty = newSet == oldSet + def nonEmpty = !isEmpty +} diff --git a/app/controllers/DependencyCheckReportsParser.scala b/app/controllers/DependencyCheckReportsParser.scala index 24809dd..9e150d9 100644 --- a/app/controllers/DependencyCheckReportsParser.scala +++ b/app/controllers/DependencyCheckReportsParser.scala @@ -75,8 +75,8 @@ private final case class BadFilter(pattern: String) extends Filter{ object DependencyCheckReportsParser{ final case class ResultWithSelection(result: Result, projectsWithSelection: ProjectsWithSelection) - final case class Result(bareFlatReports: Map[String, Analysis], bareFailedAnalysises: Map[String, Throwable], projects: Projects){ - lazy val projectsReportInfo = new ProjectsWithReports(projects, bareFlatReports.keySet ++ bareFailedAnalysises.keySet) + final case class Result(bareFlatReports: Map[String, Analysis], bareFailedAnalysises: Map[String, Throwable], projects: Projects /*TODO: maybe rename to rootProjects*/){ + lazy val projectsReportInfo = new ProjectsWithReports(projects, bareFlatReports.keySet ++ bareFailedAnalysises.keySet) // TODO: consider renaming to projectsWithReports lazy val flatReports: Map[ReportInfo, Analysis] = bareFlatReports.map{case (k, v) => projectsReportInfo.reportIdToReportInfo(k) -> v} lazy val failedAnalysises: Map[ReportInfo, Throwable] = bareFailedAnalysises.map{case (k, v) => projectsReportInfo.reportIdToReportInfo(k) -> v} lazy val allDependencies = flatReports.toSeq.flatMap(r => r._2.dependencies.map(_ -> r._1)) diff --git a/app/controllers/Notifications.scala b/app/controllers/Notifications.scala new file mode 100644 index 0000000..92770dd --- /dev/null +++ b/app/controllers/Notifications.scala @@ -0,0 +1,139 @@ +package controllers + +import javax.inject.Inject + +import com.ysoft.odc.{Absolutizer, SetDiff} +import controllers.Statistics.LibDepStatistics +import models.{EmailMessageId, ExportedVulnerability} +import play.api.i18n.MessagesApi +import play.api.libs.Crypto +import play.api.mvc.Action +import play.api.{Configuration, Logger} +import services.{EmailExportService, IssueTrackerService, LibrariesService, VulnerabilityNotificationService} + +import scala.concurrent.Future.{successful => Fut} +import scala.concurrent.{ExecutionContext, Future} + +class Notifications @Inject()( + config: Configuration, + librariesService: LibrariesService, + notificationService: VulnerabilityNotificationService, + projectReportsProvider: ProjectReportsProvider, + dependencyCheckReportsParser: DependencyCheckReportsParser, + issueTrackerServiceOption: Option[IssueTrackerService], + emailExportServiceOption: Option[EmailExportService], + absolutizer: Absolutizer, + val env: AuthEnv +)(implicit val messagesApi: MessagesApi, executionContext: ExecutionContext) extends AuthenticatedController { + + private val versions = Map[String, Int]() + + import secureRequestConversion._ + + def listProjects() = SecuredAction.async { implicit req => + val (lastRefreshTime, resultsFuture) = projectReportsProvider.resultsForVersions(versions) + val myWatchesFuture = notificationService.watchedProjectsByUser(req.identity.loginInfo).map(_.map(_.project).toSet) + for{ + (successfulReports, failedReports) <- resultsFuture + myWatches <- myWatchesFuture + } yield { + val projects = dependencyCheckReportsParser.parseReports(successfulReports).projectsReportInfo.sortedReportsInfo + Ok(views.html.notifications.index(projects, myWatches)) + } + } + + //@inline private def filterMissingTickets(missingTickets: Set[String]) = missingTickets take 1 // for debug purposes + @inline private def filterMissingTickets(missingTickets: Set[String]) = missingTickets // for production purposes + + def notifyVulnerabilities[T]( + lds: LibDepStatistics, ep: notificationService.ExportPlatform[T, _], projects: ProjectsWithReports + )( + reportVulnerability: (Vulnerability, Set[GroupedDependency]) => Future[ExportedVulnerability[T]] + )( + reportChangedProjectsForVulnerability: (Vulnerability, SetDiff[String], T) => Future[Unit] + ) = { + val vulnerabilitiesByName = lds.vulnerabilitiesToDependencies.map{case (v, deps) => (v.name, (v, deps))} + for{ + tickets <- ep.ticketsForVulnerabilities(lds.vulnerabilityNames) + // Check existing tickets + existingTicketsIds = tickets.values.map(_._1).toSet + ticketsById = tickets.values.map{case (id, ev) => id -> ev}.toMap + existingTicketsProjects <- ep.projectsForTickets(existingTicketsIds) + _ = Logger.warn("existingTicketsProjects for "+ep+": "+existingTicketsProjects.filter(_._2.exists(_.toLowerCase.contains("wps"))).toString) + projectUpdates <- Future.traverse(existingTicketsIds){ ticketId => // If we traversed over existingTicketsProjects, we would skip vulns with no projects + val oldProjectIdsSet = existingTicketsProjects(ticketId) + val exportedVulnerability = ticketsById(ticketId) + val vulnerabilityName = exportedVulnerability.vulnerabilityName + val newProjectIdsSet = vulnerabilitiesByName(vulnerabilityName)._2.flatMap(_.projects).map(_.fullId) + val diff = new SetDiff(oldSet = oldProjectIdsSet, newSet = newProjectIdsSet) + if(diff.nonEmpty) { + reportChangedProjectsForVulnerability(lds.vulnerabilitiesByName(vulnerabilityName), diff, exportedVulnerability.ticket).flatMap { _ => + ep.changeProjects(ticketId, diff, projects) + }.map( _ => Some(diff)) + } else { + Fut(None) + } + } + // Check new tickets + missingTickets = vulnerabilitiesByName.keySet -- tickets.keySet + newTicketIds <- Future.traverse(filterMissingTickets(missingTickets)){vulnerabilityName => + val (vulnerability, dependencies) = vulnerabilitiesByName(vulnerabilityName) + reportVulnerability(vulnerability, dependencies).flatMap{ ticket => + ep.addTicket(ticket, dependencies.flatMap(_.projects)).map(_ => ticket.ticket) + } + } + } yield (missingTickets, newTicketIds, projectUpdates.toSet: Set[Any]) + } + + def cron(key: String, purgeCache: Boolean) = Action.async{ + if(Crypto.constantTimeEquals(key, config.getString("yssdc.cronKey").get)){ + if(purgeCache){ + projectReportsProvider.purgeCache(Map()) + } + val (lastRefreshTime, resultsFuture) = projectReportsProvider.resultsForVersions(versions) + for{ + (successfulReports, failedReports) <- resultsFuture + libraries <- librariesService.all + parsedReports = dependencyCheckReportsParser.parseReports(successfulReports) + lds = LibDepStatistics(dependencies = parsedReports.groupedDependencies.toSet, libraries = libraries.toSet) + issuesExportResultFuture = exportToIssueTracker(lds, parsedReports.projectsReportInfo) + mailExportResultFuture = exportToEmail(lds, parsedReports.projectsReportInfo) + (missingTickets, newTicketIds, updatedTickets) <- issuesExportResultFuture + (missingEmails, newMessageIds, updatedEmails) <- mailExportResultFuture + } yield Ok( + missingTickets.mkString("\n")+"\n\n"+newTicketIds.mkString("\n")+ updatedTickets.toString+ + "\n\n" + + missingEmails.mkString("\n")+"\n\n"+newMessageIds.mkString("\n") + updatedEmails.toString + ) + }else{ + Fut(Unauthorized("unauthorized")) + } + } + + private def forService[S, T](serviceOption: Option[S])(f: S => Future[(Set[String], Set[T], Set[Any])]) = serviceOption.fold(Fut((Set[String](), Set[T](), Set[Any]())))(f) + + private def exportToEmail(lds: LibDepStatistics, p: ProjectsWithReports) = forService(emailExportServiceOption){ emailExportService => + notifyVulnerabilities[EmailMessageId](lds, notificationService.mailExport, p) { (vulnerability, dependencies) => + emailExportService.mailForVulnerability(vulnerability, dependencies).flatMap(emailExportService.sendEmail).map(id => ExportedVulnerability(vulnerability.name, EmailMessageId(id), 0)) + }{ (vuln, diff, msgid) => + emailExportService.mailForVulnerabilityProjectsChange(vuln, msgid, diff, p).flatMap(emailExportService.sendEmail).map(_ => ()) + } + } + + private def exportToIssueTracker(lds: LibDepStatistics, p: ProjectsWithReports) = forService(issueTrackerServiceOption){ issueTrackerService => + notifyVulnerabilities[String](lds, notificationService.issueTrackerExport, p) { (vulnerability, dependencies) => + issueTrackerService.reportVulnerability(vulnerability) + }{ (vuln, diff, ticket) => + Fut(()) + } + } + + def watch(project: String) = SecuredAction.async{ implicit req => + for( _ <-notificationService.subscribe(req.identity.loginInfo, project) ) yield Redirect(routes.Notifications.listProjects()) + } + + def unwatch(project: String) = SecuredAction.async{implicit req => + for( _ <-notificationService.unsubscribe(req.identity.loginInfo, project) ) yield Redirect(routes.Notifications.listProjects()) + } + +} diff --git a/app/controllers/ProjectsWithReports.scala b/app/controllers/ProjectsWithReports.scala index a81b3f2..07cb6ff 100644 --- a/app/controllers/ProjectsWithReports.scala +++ b/app/controllers/ProjectsWithReports.scala @@ -22,6 +22,8 @@ final case class ReportInfo( override def hashCode(): Int = 517+fullId.hashCode + def bare = copy(subprojectNameOption = None, fullId = fullId.takeWhile(_ != '/')) + } object ProjectsWithReports{ @@ -38,21 +40,27 @@ class ProjectsWithReports (val projects: Projects, val reports: Set[String]) { val reportIdToReportInfo = { val reportsMap = reports.map{ unfriendlyName => - val (baseName, theRest) = unfriendlyName.span(_ != '/') - val removeLeadingMess = RestMessBeginRegexp.replaceAllIn(_: String, "") - val removeTrailingMess = RestMessEndRegexp.replaceAllIn(_: String, "") - val removeMess = removeLeadingMess andThen removeTrailingMess - val subProjectOption = Some(removeMess(theRest)).filter(_ != "") - unfriendlyName -> ReportInfo( - projectId = baseName, - fullId = unfriendlyName, - projectName = projects.projectMap(baseName), - subprojectNameOption = subProjectOption.orElse(Some("root project")) - ) + unfriendlyName -> parseUnfriendlyName(unfriendlyName) }.toMap reportsMap ++ reportsMap.values.map(r => r.projectId -> ReportInfo(projectId = r.projectId, fullId = r.projectId, subprojectNameOption = None, projectName = r.projectName)) } + def parseUnfriendlyName(unfriendlyName: String): ReportInfo = { + val (baseName, theRest) = unfriendlyName.span(_ != '/') + val removeLeadingMess = RestMessBeginRegexp.replaceAllIn(_: String, "") + val removeTrailingMess = RestMessEndRegexp.replaceAllIn(_: String, "") + val removeMess = removeLeadingMess andThen removeTrailingMess + val subProjectOption = Some(removeMess(theRest)).filter(_ != "") + ReportInfo( + projectId = baseName, + fullId = unfriendlyName, + projectName = projects.projectMap(baseName), + subprojectNameOption = subProjectOption.orElse(Some("root project")) + ) + } + val ungroupedReportsInfo = reportIdToReportInfo.values.toSet + def sortedReportsInfo = ungroupedReportsInfo.toSeq.sortBy(p => p.projectName -> p.projectId -> p.subprojectNameOption) + } diff --git a/app/controllers/Statistics.scala b/app/controllers/Statistics.scala index fe05ce8..34bee39 100644 --- a/app/controllers/Statistics.scala +++ b/app/controllers/Statistics.scala @@ -8,7 +8,7 @@ import models.{Library, LibraryTag} import org.joda.time.DateTime import play.api.i18n.MessagesApi import play.twirl.api.Txt -import services.{LibrariesService, LibraryTagAssignmentsService, OdcService, TagsService} +import services._ import views.html.DefaultRequest import scala.concurrent.{ExecutionContext, Future} @@ -17,6 +17,8 @@ object Statistics{ case class LibDepStatistics(libraries: Set[(Int, Library)], dependencies: Set[GroupedDependency]){ def vulnerableRatio = vulnerableDependencies.size.toDouble / dependencies.size.toDouble lazy val vulnerabilities: Set[Vulnerability] = dependencies.flatMap(_.vulnerabilities) + lazy val vulnerabilitiesByName = vulnerabilities.map(v => v.name -> v).toMap + lazy val vulnerabilityNames = vulnerabilities.map(_.name) lazy val vulnerabilitiesToDependencies: Map[Vulnerability, Set[GroupedDependency]] = vulnerableDependencies.flatMap(dep => dep.vulnerabilities.map(vuln => (vuln, dep)) ).groupBy(_._1).mapValues(_.map(_._2)).map(identity) @@ -50,6 +52,8 @@ class Statistics @Inject() ( libraryTagAssignmentsService: LibraryTagAssignmentsService, @Named("missing-GAV-exclusions") missingGAVExclusions: MissingGavExclusions, projects: Projects, + vulnerabilityNotificationService: VulnerabilityNotificationService, + issueTrackerServiceOption: Option[IssueTrackerService], val env: AuthEnv )(implicit val messagesApi: MessagesApi, executionContext: ExecutionContext) extends AuthenticatedController { @@ -167,14 +171,19 @@ class Statistics @Inject() ( name = name, projectsWithSelection = selection.projectsWithSelection )))){ case (vuln, vulnerableDependencies) => - for( + for { plainLibs <- librariesService.byPlainLibraryIdentifiers(vulnerableDependencies.flatMap(_.plainLibraryIdentifiers)).map(_.keySet) - ) yield Ok(views.html.statistics.vulnerability( + ticketOption <- vulnerabilityNotificationService.issueTrackerExport.ticketForVulnerability(name) + } yield Ok(views.html.statistics.vulnerability( vulnerability = vuln, affectedProjects = vulnerableDependencies.flatMap(dep => dep.projects.map(proj => (proj, dep))).groupBy(_._1).mapValues(_.map(_._2)), vulnerableDependencies = vulnerableDependencies, affectedLibraries = plainLibs, - projectsWithSelection = selection.projectsWithSelection + projectsWithSelection = selection.projectsWithSelection, + issueOption = for{ + ticket <- ticketOption + issueTrackerService <- issueTrackerServiceOption + } yield ticket -> issueTrackerService.ticketLink(ticket) )) } diff --git a/app/models/EmailMessageId.scala b/app/models/EmailMessageId.scala new file mode 100644 index 0000000..58f888c --- /dev/null +++ b/app/models/EmailMessageId.scala @@ -0,0 +1,5 @@ +package models + +case class EmailMessageId(messageId: String) extends AnyVal { + def validIdOption = Some(messageId).filterNot(_ == "") // Prevents using invalid empty string when using mock +} \ No newline at end of file diff --git a/app/models/ExportPlatformTables.scala b/app/models/ExportPlatformTables.scala new file mode 100644 index 0000000..342d719 --- /dev/null +++ b/app/models/ExportPlatformTables.scala @@ -0,0 +1,9 @@ +package models + +import models.profile.api._ + +abstract class ExportPlatformTables[T, U] private[models] () { + val tickets: TableQuery[_ <: ExportedVulnerabilities[T, U]] + val projects: TableQuery[_ <: ExportedVulnerabilityProjects] + def schema = tickets.schema ++ projects.schema +} diff --git a/app/models/ExportedVulnerability.scala b/app/models/ExportedVulnerability.scala new file mode 100644 index 0000000..317c924 --- /dev/null +++ b/app/models/ExportedVulnerability.scala @@ -0,0 +1,19 @@ +package models + +import models.profile.api._ +import slick.lifted.{MappedProjection, Tag} + + +case class ExportedVulnerability[T] (vulnerabilityName: String, ticket: T, ticketFormatVersion: Int/*, maintainedAutomatically: Boolean*/) + +abstract class ExportedVulnerabilities[T, U](tag: Tag, tableNamePart: String) extends Table[(Int, ExportedVulnerability[T])](tag, s"exported_${tableNamePart}_vulnerabilities"){ + def id = column[Int]("id", O.PrimaryKey, O.AutoInc) + def vulnerabilityName = column[String]("vulnerability_name") + def ticketFormatVersion = column[Int]("ticket_format_version") + //def maintainedAutomatically = column[Boolean]("maintained_automatically") + + def base: MappedProjection[ExportedVulnerability[T], U]// = (vulnerabilityName, ticket, ticketFormatVersion) <> ((ExportedVulnerability.apply[T] _).tupled, ExportedVulnerability.unapply[T]) + def * = (id, base) + + def idx_vulnerabilityName = index(s"idx_${tableName}_vulnerabilityName", vulnerabilityName, unique = true) +} \ No newline at end of file diff --git a/app/models/ExportedVulnerabilityProject.scala b/app/models/ExportedVulnerabilityProject.scala new file mode 100644 index 0000000..bdc6c51 --- /dev/null +++ b/app/models/ExportedVulnerabilityProject.scala @@ -0,0 +1,15 @@ +package models + +import models.profile.MappedJdbcType +import models.profile.api._ +import slick.lifted.ProvenShape + +case class ExportedVulnerabilityProject(exportedVulnerabilityId: Int, projectFullId: String) + +abstract class ExportedVulnerabilityProjects(tag: Tag, tableNamePart: String) extends Table[ExportedVulnerabilityProject](tag, s"exported_${tableNamePart}_vulnerability_projects"){ + def exportedVulnerabilityId = column[Int]("exported_vulnerability_id") + def fullProjectId = column[String]("full_project_id") + def idx_all = index(s"idx_${tableName}_all", shape, unique = true) + private def shape = (exportedVulnerabilityId, fullProjectId) + override def * = shape <> (ExportedVulnerabilityProject.tupled, ExportedVulnerabilityProject.unapply) +} diff --git a/app/models/Library.scala b/app/models/Library.scala index d0d9409..a0782a3 100644 --- a/app/models/Library.scala +++ b/app/models/Library.scala @@ -2,7 +2,6 @@ package models import models.profile.MappedJdbcType import models.profile.api._ -import slick.lifted.Tag abstract sealed class LibraryType(val name: String){ override final def toString: String = name diff --git a/app/models/VulnerabilitySubscription.scala b/app/models/VulnerabilitySubscription.scala new file mode 100644 index 0000000..c5c8423 --- /dev/null +++ b/app/models/VulnerabilitySubscription.scala @@ -0,0 +1,22 @@ +package models + +import models.profile.api._ +import slick.lifted.Tag +import com.mohiva.play.silhouette.api.LoginInfo + +case class VulnerabilitySubscription(user: LoginInfo, project: String) + +class LoginInfoColumns(prefix: String, table: Table[_]) { + import table.column + def providerId = column[String](s"${prefix}_provider_id") + def providerKey = column[String](s"${prefix}_provider_key") + def apply() = (providerId, providerKey) <> (LoginInfo.tupled, LoginInfo.unapply) + def === (other: LoginInfo): Rep[Boolean] = (providerId === other.providerID) && (providerKey === other.providerKey) +} + +class VulnerabilitySubscriptions(tag: Tag) extends Table[VulnerabilitySubscription](tag, "vulnerability_subscription"){ + val user = new LoginInfoColumns("subscriber", this) + def project = column[String]("project") + def * = (user(), project) <> (VulnerabilitySubscription.tupled, VulnerabilitySubscription.unapply) + def idx = index("all", (user(), project), unique = true) +} \ No newline at end of file diff --git a/app/models/package.scala b/app/models/package.scala index 94d2e67..4a0f2d9 100644 --- a/app/models/package.scala +++ b/app/models/package.scala @@ -1,13 +1,15 @@ -import slick.lifted.TableQuery -/** - * Created by user on 8/12/15. - */ +import java.nio.file.{Paths, Files} + +import scala.language.reflectiveCalls + package object models { - val profile = slick.driver.PostgresDriver val jodaSupport = com.github.tototoshi.slick.PostgresJodaSupport + import profile.api._ + import profile.MappedJdbcType + object tables { val libraries = TableQuery[Libraries] @@ -15,6 +17,50 @@ package object models { val tags = TableQuery[LibraryTags] val snoozesTable = TableQuery[Snoozes] val authTokens = TableQuery[CookieAuthenticators] + val vulnerabilitySubscriptions = TableQuery[VulnerabilitySubscriptions] + + val issueTrackerExportTables = new ExportPlatformTables[String, (String, String, Int)](){ + val tableNamePart = "issue_tracker" + class IssueTrackerVulnerabilities(tag: Tag) extends ExportedVulnerabilities[String, (String, String, Int)](tag, tableNamePart){ + def ticket = column[String]("ticket") + override def base = (vulnerabilityName, ticket, ticketFormatVersion) <> ((ExportedVulnerability.apply[String] _).tupled, ExportedVulnerability.unapply[String]) + def idx_ticket = index("idx_ticket", ticket, unique = true) + } + class IssueTrackerVulnerabilityProject(tag: Tag) extends ExportedVulnerabilityProjects(tag, tableNamePart) + override val tickets = TableQuery[IssueTrackerVulnerabilities] + override val projects: profile.api.TableQuery[_ <: ExportedVulnerabilityProjects] = TableQuery[IssueTrackerVulnerabilityProject] + } + type EmailExportedVulnerabilitiesShape = (String, EmailMessageId, Int) + val mailExportTables = new ExportPlatformTables[EmailMessageId, EmailExportedVulnerabilitiesShape](){ + val tableNamePart = "email" + class EmailExportedVulnerabilities(tag: Tag) extends ExportedVulnerabilities[EmailMessageId, EmailExportedVulnerabilitiesShape](tag, tableNamePart){ + private implicit val mmiMapper = MappedJdbcType.base[EmailMessageId, String](_.messageId, EmailMessageId) + def messageId = column[EmailMessageId]("message_id") // Unlike ticket, message id is not required to be unique in order to handle some edge cases like play.mailer.mock = true + override def base = (vulnerabilityName, messageId, ticketFormatVersion) <> ( (ExportedVulnerability.apply[EmailMessageId] _).tupled, ExportedVulnerability.unapply[EmailMessageId]) + } + class EmailVulnerabilityProject(tag: Tag) extends ExportedVulnerabilityProjects(tag, tableNamePart) + + override val projects = TableQuery[EmailVulnerabilityProject] + override val tickets = TableQuery[EmailExportedVulnerabilities] + } + + /*{ + import profile.SchemaDescription + val schema = Seq[Any{def schema: SchemaDescription}]( + vulnerabilitySubscriptions, issueTrackerExportTables, mailExportTables + ).map(_.schema).foldLeft(profile.DDL(Seq(), Seq()))(_ ++ _) + + val sql = Seq( + "# --- !Ups", + schema.createStatements.toSeq.map(_+";").mkString("\n").dropWhile(_ == "\n"), + "", + "# --- !Downs", + schema.dropStatements.toSeq.map(_+";").mkString("\n").dropWhile(_ == "\n"), + "\n" + ).mkString("\n") + Files.write(Paths.get("conf/evolutions/default/6.sql"), sql.getBytes("utf-8")) + }*/ + } } diff --git a/app/modules/ConfigModule.scala b/app/modules/ConfigModule.scala index ac7e99f..cf3eb09 100644 --- a/app/modules/ConfigModule.scala +++ b/app/modules/ConfigModule.scala @@ -3,14 +3,19 @@ package modules import java.io._ import java.net.URLEncoder import java.nio.file.{Files, Path, Paths} +import java.util.concurrent.Executors import akka.util.ClassLoaderObjectInputStream import com.ysoft.odc._ import controllers.MissingGavExclusions +import net.ceedubs.ficus.Ficus._ +import net.ceedubs.ficus.readers.ArbitraryTypeReader._ import play.api.cache.CacheApi import play.api.inject.{Binding, Module} import play.api.{Configuration, Environment, Logger} +import services.IssueTrackerService +import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration import scala.reflect.ClassTag import scala.util.{Failure, Success, Try} @@ -22,6 +27,7 @@ import scala.util.{Failure, Success, Try} * * Thread safety * * fsync: https://stackoverflow.com/questions/4072878/i-o-concept-flush-vs-sync * * probably not removing files that are not used for a long time + * * @param path */ class FileCacheApi(path: Path) extends CacheApi{ @@ -79,7 +85,10 @@ class FileCacheApi(path: Path) extends CacheApi{ class ConfigModule extends Module { - + + private val bambooAuthentication = bind[AtlassianAuthentication].qualifiedWith("bamboo-authentication") + //private val jiraAuthentication = bind[AtlassianAuthentication].qualifiedWith("jira-authentication") + override def bindings(environment: Environment, configuration: Configuration): Seq[Binding[_]] = Seq( bind[String].qualifiedWith("bamboo-server-url").toInstance(configuration.getString("yssdc.bamboo.url").getOrElse(sys.error("Key yssdc.bamboo.url is not set"))), configuration.getString("yssdc.reports.provider") match{ @@ -89,11 +98,13 @@ class ConfigModule extends Module { }, bind[MissingGavExclusions].qualifiedWith("missing-GAV-exclusions").toInstance(MissingGavExclusions( configuration.getStringSeq("yssdc.exclusions.missingGAV.bySha1").getOrElse(Seq()).toSet.map(Exclusion)) - ) + ), + bind[ExecutionContext].qualifiedWith("email-sending").toInstance(ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor())) ) ++ + configuration.underlying.getAs[Absolutizer]("app").map(a => bind[Absolutizer].toInstance(a)) ++ configuration.getString("play.cache.path").map(cachePath => bind[CacheApi].toInstance(new FileCacheApi(Paths.get(cachePath)))) ++ - configuration.getString("yssdc.reports.bamboo.sessionId").map{s => bind[BambooAuthentication].toInstance(new SessionIdBambooAuthentication(s))} ++ - configuration.getString("yssdc.reports.bamboo.user").map{u => bind[BambooAuthentication].toInstance(new CredentialsBambooAuthentication(u, configuration.getString("yssdc.reports.bamboo.password").get))} ++ + configuration.getString("yssdc.reports.bamboo.sessionId").map{s => bambooAuthentication.toInstance(new SessionIdAtlassianAuthentication(s))} ++ + configuration.getString("yssdc.reports.bamboo.user").map{u => bambooAuthentication.toInstance(new CredentialsAtlassianAuthentication(u, configuration.getString("yssdc.reports.bamboo.password").get))} ++ configuration.getString("yssdc.reports.path").map{s => bind[String].qualifiedWith("reports-path").toInstance(s)} } diff --git a/app/modules/EmailExportModule.scala b/app/modules/EmailExportModule.scala new file mode 100644 index 0000000..759686c --- /dev/null +++ b/app/modules/EmailExportModule.scala @@ -0,0 +1,33 @@ +package modules + +import javax.inject.Named + +import com.google.inject.{AbstractModule, Provides} +import com.ysoft.odc.Absolutizer +import net.ceedubs.ficus.Ficus._ +import net.codingwell.scalaguice.ScalaModule +import play.api.Configuration +import play.api.libs.mailer.MailerClient +import services.{EmailExportService, VulnerabilityNotificationService} + +import scala.concurrent.ExecutionContext + +class EmailExportModule extends AbstractModule with ScalaModule{ + override def configure(): Unit = { + } + + @Provides + def provideIssueTrackerOption(conf: Configuration, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, absolutizer: Absolutizer, @Named("email-sending") emailSendingExecutionContext: ExecutionContext)(implicit executionContext: ExecutionContext): Option[EmailExportService] = { + println(s"emailSendingExecutionContext = $emailSendingExecutionContext") + conf.getConfig("yssdc.export.email").map{c => + new EmailExportService( + from = c.underlying.as[String]("from"), + mailerClient = mailerClient, + emailSendingExecutionContext = emailSendingExecutionContext, + absolutizer = absolutizer, + notificationService = notificationService, + nobodyInterestedContact = c.underlying.as[String]("noSubscriberContact") + ) + } + } +} diff --git a/app/modules/IssueTrackerExportModule.scala b/app/modules/IssueTrackerExportModule.scala new file mode 100644 index 0000000..4a3180b --- /dev/null +++ b/app/modules/IssueTrackerExportModule.scala @@ -0,0 +1,39 @@ +package modules + +import com.google.inject.{AbstractModule, Provides} +import com.ysoft.odc.{Absolutizer, CredentialsAtlassianAuthentication} +import net.ceedubs.ficus.Ficus._ +import net.ceedubs.ficus.readers.ArbitraryTypeReader._ +import net.codingwell.scalaguice.ScalaModule +import play.api.Configuration +import play.api.libs.ws.WSClient +import services.{IssueTrackerService, JiraIssueTrackerService} + +import scala.concurrent.ExecutionContext + +class IssueTrackerExportModule extends AbstractModule with ScalaModule{ + override def configure(): Unit = { + } + + @Provides + def provideIssueTrackerOption(conf: Configuration, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext, wSClient: WSClient): Option[IssueTrackerService] = { + conf.getConfig("yssdc.export.issueTracker").map(issueTrackerConfiguration(absolutizer)) + } + + private def issueTrackerConfiguration(absolutizer: Absolutizer)(conf: Configuration)(implicit executionContext: ExecutionContext, wSClient: WSClient): IssueTrackerService = conf.getString("provider") match{ + case Some("jira") => + conf.getString("authentication.type") match { + case Some("credentials") => + case other => sys.error("unknown authentication type: "+other) + } + new JiraIssueTrackerService( + absolutizer = absolutizer, + server = conf.underlying.as[String]("server"), + projectId = conf.underlying.as[Int]("projectId"), + vulnerabilityIssueType = conf.underlying.as[Int]("vulnerabilityIssueType"), + atlassianAuthentication = conf.underlying.as[CredentialsAtlassianAuthentication]("authentication") + ) + case other => sys.error("unknown provider for issue tracker: "+other) + } + +} diff --git a/app/services/EmailExportService.scala b/app/services/EmailExportService.scala new file mode 100644 index 0000000..322be40 --- /dev/null +++ b/app/services/EmailExportService.scala @@ -0,0 +1,64 @@ +package services + +import java.util.NoSuchElementException +import javax.inject.Named + +import com.ysoft.odc.{SetDiff, Absolutizer} +import controllers._ +import models.EmailMessageId +import play.api.libs.mailer.{MailerClient, Email} + +import scala.concurrent.{ExecutionContext, Future} + +class EmailExportService(from: String, nobodyInterestedContact: String, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, emailSendingExecutionContext: ExecutionContext, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext) { + + def recipientsForProjects(projects: Set[ReportInfo]) = for{ + recipients <- notificationService.getRecipientsForProjects(projects) + } yield { + recipients.map(_.providerKey) match { // TODO: get the email in a cleaner way + case Seq() => Seq(nobodyInterestedContact) -> false + case other => other -> true + } + } + + def mailForVulnerabilityProjectsChange(vuln: Vulnerability, emailMessageId: EmailMessageId, diff: SetDiff[String], projects: ProjectsWithReports) = { + def showProjects(s: Set[String]) = s.map(p => + "* " + (try{ + friendlyProjectName(projects.parseUnfriendlyName(p)) + }catch{ // It might fail on project that has been removed + case e: NoSuchElementException => s"unknown project $p" + }) + ).mkString("\n") + for{ + (recipients, somebodySubscribed) <- recipientsForProjects(diff.added.map(projects.parseUnfriendlyName)) + } yield Email( + subject = s"[${vuln.name}] Modified vulnerability${if(!somebodySubscribed) ", nobody is subscribed for that" else "" }", + from = from, + to = Seq(), + replyTo = emailMessageId.validIdOption, + headers = emailMessageId.validIdOption.map("References" -> _).toSeq, + bcc = recipients, + bodyText = Some( + "New projects affected by the vulnerability: \n"+showProjects(diff.added) + "\n\n" + + "Projects no longer affected by the vulnerability: \n"+showProjects(diff.removed) + "\n\n" + + s"More details: "+absolutizer.absolutize(routes.Statistics.vulnerability(vuln.name, None)) + ) + ) + } + + + def sendEmail(email: Email): Future[String] = Future{ + mailerClient.send(email) + }(emailSendingExecutionContext) + + def mailForVulnerability(vulnerability: Vulnerability, dependencies: Set[GroupedDependency]) = for { + (recipientEmails, somebodySubscribed) <- recipientsForProjects(dependencies.flatMap(_.projects)) + } yield Email( + subject = s"[${vulnerability.name}] New vulnerability${if(!somebodySubscribed) ", nobody is subscribed for that" else "" }", + from = from, + to = Seq(), + bcc = recipientEmails, + bodyText = Some(vulnerability.description + "\n\n" + s"More details: "+absolutizer.absolutize(routes.Statistics.vulnerability(vulnerability.name, None))) + ) + +} diff --git a/app/services/IssueTrackerService.scala b/app/services/IssueTrackerService.scala new file mode 100644 index 0000000..780faa4 --- /dev/null +++ b/app/services/IssueTrackerService.scala @@ -0,0 +1,12 @@ +package services + +import controllers.Vulnerability +import models.ExportedVulnerability + +import scala.concurrent.Future + +trait IssueTrackerService { + def reportVulnerability(vulnerability: Vulnerability): Future[ExportedVulnerability[String]] + def ticketLink(ticket: String): String + def ticketLink(ticket: ExportedVulnerability[String]): String = ticketLink(ticket.ticket) +} diff --git a/app/services/JiraIssueTrackerService.scala b/app/services/JiraIssueTrackerService.scala new file mode 100644 index 0000000..838e666 --- /dev/null +++ b/app/services/JiraIssueTrackerService.scala @@ -0,0 +1,60 @@ +package services + +import javax.inject.Inject + +import com.google.inject.name.Named +import com.ysoft.odc.{Absolutizer, AtlassianAuthentication} +import controllers.{Vulnerability, routes} +import models.ExportedVulnerability +import play.api.libs.json.Json.JsValueWrapper +import play.api.libs.json.{JsObject, Json} +import play.api.libs.ws.{WS, WSClient} + +import scala.concurrent.{ExecutionContext, Future} + +private case class JiraNewIssueResponse(id: String, key: String, self: String) + +/** + * status: WIP + * It basically works, but there is much to be discussed and implemented. + */ +class JiraIssueTrackerService @Inject() (absolutizer: Absolutizer, @Named("jira-server") server: String, @Named("jira-project-id") projectId: Int, @Named("jira-vulnerability-issue-type") vulnerabilityIssueType: Int, @Named("jira-authentication") atlassianAuthentication: AtlassianAuthentication)(implicit executionContext: ExecutionContext, wSClient: WSClient) extends IssueTrackerService{ + private def jiraUrl(url: String) = atlassianAuthentication.addAuth(WS.clientUrl(url)) + + private val formatVersion = 1 + + override def reportVulnerability(vulnerability: Vulnerability): Future[ExportedVulnerability[String]] = jiraUrl(server+"/rest/api/2/issue").post(Json.obj( + "fields" -> (extractInitialFields(vulnerability) ++ extractManagedFields(vulnerability)) + )).map(response => // returns responses like {"id":"1234","key":"PROJ-6","self":"https://…/rest/api/2/issue/1234"} + try{ + val issueInfo = Json.reads[JiraNewIssueResponse].reads(response.json).get + ExportedVulnerability(vulnerabilityName = vulnerability.name, ticket = issueInfo.key, ticketFormatVersion = formatVersion) + }catch{ + case e:Throwable=>sys.error("bad data: "+response.body) + } + ) + + private def extractInitialFields(vulnerability: Vulnerability): JsObject = Json.obj( + "project" -> Json.obj( + "id" -> projectId.toString + ), + "summary" -> s"${vulnerability.name} – ${vulnerability.cweOption.map(_ + ": ").getOrElse("")}${vulnerability.description.take(50)}…" + ) + + private def extractManagedFields(vulnerability: Vulnerability): JsObject = Json.obj( + "issuetype" -> Json.obj( + "id" -> vulnerabilityIssueType.toString + ), + "description" -> extractDescription(vulnerability) + // TODO: add affected releases + // TODO: add affected projects + //"customfield_10100" -> Json.arr("xxxx") + ) + + private def extractDescription(vulnerability: Vulnerability): JsValueWrapper = { + vulnerability.description + "\n\n" + s"Details: ${absolutizer.absolutize(routes.Statistics.vulnerability(vulnerability.name, None))}" + } + + override def ticketLink(ticket: String): String = s"$server/browse/$ticket" + +} diff --git a/app/services/VulnerabilityNotificationService.scala b/app/services/VulnerabilityNotificationService.scala new file mode 100644 index 0000000..abbe4ee --- /dev/null +++ b/app/services/VulnerabilityNotificationService.scala @@ -0,0 +1,68 @@ +package services + +import com.google.inject.Inject +import com.mohiva.play.silhouette.api.LoginInfo +import com.ysoft.odc.SetDiff +import controllers.{ProjectsWithReports, ReportInfo} +import models._ +import play.api.db.slick.{HasDatabaseConfigProvider, DatabaseConfigProvider} + +import scala.collection.immutable.Iterable +import scala.concurrent.{Future, ExecutionContext} + +class VulnerabilityNotificationService @Inject() (protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends HasDatabaseConfigProvider[models.profile.type]{ + import dbConfig.driver.api._ + + import models.tables + import models.tables.vulnerabilitySubscriptions + + def watchedProjectsByUser(identity: LoginInfo) = db.run(vulnerabilitySubscriptions.filter(_.user === identity).result) + def subscribe(user: LoginInfo, project: String) = db.run(vulnerabilitySubscriptions += VulnerabilitySubscription(user = user, project = project)) + + def unsubscribe(user: LoginInfo, project: String) = db.run(vulnerabilitySubscriptions.filter(vs => vs.user === user && vs.project === project).delete) + + def getRecipientsForProjects(projects: Set[ReportInfo]) = { + val bareProjects = projects.map(_.bare) + val expandedProjects = projects ++ bareProjects + val relevantFullIds = expandedProjects.map(_.fullId) + db.run(vulnerabilitySubscriptions.filter(_.project inSet relevantFullIds).map(_.user()).result) + } + + class ExportPlatform[T, U] private[VulnerabilityNotificationService] (ept: ExportPlatformTables[T, U]) { + def changeProjects(ticketId: Int, diff: SetDiff[String], projects: ProjectsWithReports) = db.run( + DBIO.seq( + ept.projects.filter(_.exportedVulnerabilityId === ticketId).delete, + ept.projects ++= diff.newSet.map(fullId => ExportedVulnerabilityProject(ticketId, fullId)).toSet + ).transactionally + ) + + def projectsForTickets(ticketsIds: Set[Int]): Future[Map[Int, Set[String]]] = db.run( + ept.projects.filter(_.exportedVulnerabilityId inSet ticketsIds).result + ).map{_.groupBy(_.exportedVulnerabilityId).mapValues(_.map(_.projectFullId).toSet).map(identity).withDefaultValue(Set())} + + def ticketsForVulnerabilities(vulnerabilities: Traversable[String]) = db.run( + ept.tickets.filter(_.vulnerabilityName inSet vulnerabilities).result + ).map(_.map{ rec => + rec._2.vulnerabilityName -> rec + }.toMap) + + def ticketForVulnerability(vulnerabilityName: String) = db.run( + ept.tickets.filter(_.vulnerabilityName === vulnerabilityName).map(_.base).result + ).map(_.headOption) + + + def addTicket(vulnerabilityTicket: ExportedVulnerability[T], projects: Set[ReportInfo]): Future[Any] = db.run( + ( + ept.tickets.map(_.base).returning(ept.tickets.map(_.id)) += vulnerabilityTicket + ).flatMap( id => + ept.projects ++= projects.map(ri => ExportedVulnerabilityProject(id, ri.fullId)).toSet + ).transactionally + ) + + } + + val issueTrackerExport = new ExportPlatform(tables.issueTrackerExportTables) + + val mailExport = new ExportPlatform(tables.mailExportTables) + +} diff --git a/app/views/main.scala.html b/app/views/main.scala.html index 26aecf7..febdd19 100644 --- a/app/views/main.scala.html +++ b/app/views/main.scala.html @@ -49,6 +49,7 @@