mirror of
https://github.com/ysoftdevs/odc-analyzer.git
synced 2026-01-15 08:14:02 +01:00
Added support for mail notifications and WIP JIRA export.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -113,4 +113,8 @@ h3.library-identification{
|
||||
.help{
|
||||
border-left: 5px solid #00c7ff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.projects-watching .watched{
|
||||
font-weight: bold;
|
||||
}
|
||||
7
app/com/ysoft/odc/Absolutizer.scala
Normal file
7
app/com/ysoft/odc/Absolutizer.scala
Normal file
@@ -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)
|
||||
}
|
||||
15
app/com/ysoft/odc/AtlassianAuthentication.scala
Normal file
15
app/com/ysoft/odc/AtlassianAuthentication.scala
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
8
app/com/ysoft/odc/SetDiff.scala
Normal file
8
app/com/ysoft/odc/SetDiff.scala
Normal file
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
139
app/controllers/Notifications.scala
Normal file
139
app/controllers/Notifications.scala
Normal file
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
5
app/models/EmailMessageId.scala
Normal file
5
app/models/EmailMessageId.scala
Normal file
@@ -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
|
||||
}
|
||||
9
app/models/ExportPlatformTables.scala
Normal file
9
app/models/ExportPlatformTables.scala
Normal file
@@ -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
|
||||
}
|
||||
19
app/models/ExportedVulnerability.scala
Normal file
19
app/models/ExportedVulnerability.scala
Normal file
@@ -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)
|
||||
}
|
||||
15
app/models/ExportedVulnerabilityProject.scala
Normal file
15
app/models/ExportedVulnerabilityProject.scala
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
22
app/models/VulnerabilitySubscription.scala
Normal file
22
app/models/VulnerabilitySubscription.scala
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}*/
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
}
|
||||
|
||||
33
app/modules/EmailExportModule.scala
Normal file
33
app/modules/EmailExportModule.scala
Normal file
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/modules/IssueTrackerExportModule.scala
Normal file
39
app/modules/IssueTrackerExportModule.scala
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
64
app/services/EmailExportService.scala
Normal file
64
app/services/EmailExportService.scala
Normal file
@@ -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)))
|
||||
)
|
||||
|
||||
}
|
||||
12
app/services/IssueTrackerService.scala
Normal file
12
app/services/IssueTrackerService.scala
Normal file
@@ -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)
|
||||
}
|
||||
60
app/services/JiraIssueTrackerService.scala
Normal file
60
app/services/JiraIssueTrackerService.scala
Normal file
@@ -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"
|
||||
|
||||
}
|
||||
68
app/services/VulnerabilityNotificationService.scala
Normal file
68
app/services/VulnerabilityNotificationService.scala
Normal file
@@ -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)
|
||||
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
<li><a href="@routes.Statistics.basic(None)">Tag statistics</a></li>
|
||||
<li><a href="@routes.Statistics.vulnerabilities(None, None)">Vulnerabilities</a></li>
|
||||
<li><a href="@routes.Statistics.vulnerableLibraries(None)">Vulnerable libraries</a></li>
|
||||
<li><a href="@routes.Notifications.listProjects()">Notifications</a></li>
|
||||
<li>
|
||||
@for((ProjectsWithSelection(filter, projects, teams), link) <- projectsOption){
|
||||
<div id="project-selector">
|
||||
@@ -62,7 +63,7 @@
|
||||
@for(team <- teams){
|
||||
<li><a href="@link(Some("team:"+team.id))" title="team leader: @team.leader">@team.name</a></li>
|
||||
}
|
||||
@for(report <- projects.ungroupedReportsInfo.toSeq.sortBy(p => p.projectName -> p.projectId -> p.subprojectNameOption)){
|
||||
@for(report <- projects.sortedReportsInfo){
|
||||
<li@if(report.subprojectNameOption.isEmpty){ class="base-project"}><a href="@link(Some("project:"+report.fullId))">@friendlyProjectName(report)</a></li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
32
app/views/notifications/index.scala.html
Normal file
32
app/views/notifications/index.scala.html
Normal file
@@ -0,0 +1,32 @@
|
||||
@(projects: Seq[ReportInfo], watchedProjects: Set[String])(implicit req: DefaultRequest)
|
||||
@import helper._
|
||||
@button(action: Call)(label: String) = {
|
||||
@form(action, 'style -> "display: inline-block"){
|
||||
@CSRF.formField
|
||||
<button type="submit" class="btn">@label</button>
|
||||
}
|
||||
}
|
||||
@main("Watch projects"){
|
||||
<ul class="projects-watching">
|
||||
@for(
|
||||
project <- projects;
|
||||
fullId = project.fullId;
|
||||
isWatchedThroughParent = project.subprojectNameOption.isDefined && (watchedProjects contains project.projectId);
|
||||
isWatchedDirectly = watchedProjects contains fullId;
|
||||
isWatched = isWatchedDirectly || isWatchedThroughParent
|
||||
){
|
||||
<li @if(isWatched){class="watched"}>
|
||||
@friendlyProjectName(project)
|
||||
@if(isWatchedThroughParent){
|
||||
<button disabled class="btn">unwatch</button>
|
||||
<span class="badge">watched through parent</span>
|
||||
}else{
|
||||
@if(isWatchedDirectly){
|
||||
@button(routes.Notifications.unwatch(fullId))("unwatch")
|
||||
}else{
|
||||
@button(routes.Notifications.watch(fullId))("watch")
|
||||
}
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
vulnerability: Vulnerability,
|
||||
affectedProjects: Map[ReportInfo, Set[GroupedDependency]],
|
||||
vulnerableDependencies: Set[GroupedDependency],
|
||||
affectedLibraries: Set[PlainLibraryIdentifier]
|
||||
affectedLibraries: Set[PlainLibraryIdentifier],
|
||||
issueOption: Option[(ExportedVulnerability[String], String)]
|
||||
)(implicit header: DefaultRequest)
|
||||
@section = @{views.html.genericSection("vuln")("h2") _}
|
||||
@main(
|
||||
@@ -13,6 +14,9 @@
|
||||
@if(projectsWithSelection.isProjectSpecified){
|
||||
<div class="alert alert-warning">The vulnerability details are limited to some subset of projects.<br><a class="btn btn-default" href="@routes.Statistics.vulnerability(vulnerability.name, None)">Show it for all projects!</a></div>
|
||||
}
|
||||
@for((ticket, issueLink) <- issueOption){
|
||||
<a class="btn btn-block btn-primary" href="@issueLink">Issue in your issue tracker: @ticket.ticket</a>
|
||||
}
|
||||
@section("details", "Vulnerability details") {
|
||||
@views.html.vulnerability("h2", "vuln-details", vulnerability)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ libraryDependencies += "net.ceedubs" %% "ficus" % "1.1.2"
|
||||
|
||||
libraryDependencies += "org.owasp" % "dependency-check-core" % "1.3.0"
|
||||
|
||||
libraryDependencies += "com.typesafe.play" %% "play-mailer" % "3.0.1"
|
||||
|
||||
routesImport += "binders.QueryBinders._"
|
||||
|
||||
// Uncomment to use Akka
|
||||
|
||||
20
conf/evolutions/default/6.sql
Normal file
20
conf/evolutions/default/6.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
# --- !Ups
|
||||
create table "vulnerability_subscription" ("subscriber_provider_id" VARCHAR NOT NULL,"subscriber_provider_key" VARCHAR NOT NULL,"project" VARCHAR NOT NULL);
|
||||
create unique index "all" on "vulnerability_subscription" ("subscriber_provider_id","subscriber_provider_key","project");
|
||||
create table "exported_issue_tracker_vulnerabilities" ("id" SERIAL NOT NULL PRIMARY KEY,"vulnerability_name" VARCHAR NOT NULL,"ticket" VARCHAR NOT NULL,"ticket_format_version" INTEGER NOT NULL);
|
||||
create unique index "idx_exported_issue_tracker_vulnerabilities_vulnerabilityName" on "exported_issue_tracker_vulnerabilities" ("vulnerability_name");
|
||||
create unique index "idx_ticket" on "exported_issue_tracker_vulnerabilities" ("ticket");
|
||||
create table "exported_issue_tracker_vulnerability_projects" ("exported_vulnerability_id" INTEGER NOT NULL,"full_project_id" VARCHAR NOT NULL);
|
||||
create unique index "idx_exported_issue_tracker_vulnerability_projects_all" on "exported_issue_tracker_vulnerability_projects" ("exported_vulnerability_id","full_project_id");
|
||||
create table "exported_email_vulnerabilities" ("id" SERIAL NOT NULL PRIMARY KEY,"vulnerability_name" VARCHAR NOT NULL,"message_id" VARCHAR NOT NULL,"ticket_format_version" INTEGER NOT NULL);
|
||||
create unique index "idx_exported_email_vulnerabilities_vulnerabilityName" on "exported_email_vulnerabilities" ("vulnerability_name");
|
||||
create table "exported_email_vulnerability_projects" ("exported_vulnerability_id" INTEGER NOT NULL,"full_project_id" VARCHAR NOT NULL);
|
||||
create unique index "idx_exported_email_vulnerability_projects_all" on "exported_email_vulnerability_projects" ("exported_vulnerability_id","full_project_id");
|
||||
|
||||
# --- !Downs
|
||||
drop table "exported_email_vulnerability_projects";
|
||||
drop table "exported_email_vulnerabilities";
|
||||
drop table "exported_issue_tracker_vulnerability_projects";
|
||||
drop table "exported_issue_tracker_vulnerabilities";
|
||||
drop table "vulnerability_subscription";
|
||||
|
||||
4
conf/reference.conf
Normal file
4
conf/reference.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
play.modules.enabled += "modules.ConfigModule"
|
||||
play.modules.enabled += "modules.SilhouetteModule"
|
||||
play.modules.enabled += "modules.IssueTrackerExportModule"
|
||||
play.modules.enabled += "modules.EmailExportModule"
|
||||
@@ -25,6 +25,11 @@ GET /stats/libraries/vulnerable controllers.Statistics.vulnerableL
|
||||
GET /stats/libraries/all controllers.Statistics.allLibraries(selector: Option[String])
|
||||
GET /stats/libraries/gavs controllers.Statistics.allGavs(selector: Option[String])
|
||||
|
||||
GET /notifications controllers.Notifications.listProjects()
|
||||
POST /notifications/watch controllers.Notifications.watch(project: String)
|
||||
POST /notifications/unwatch controllers.Notifications.unwatch(project: String)
|
||||
GET /notifications/cron/:key controllers.Notifications.cron(key: String, purgeCache: Boolean ?= true)
|
||||
|
||||
GET /libraries/vulnerabilities controllers.Statistics.searchVulnerableSoftware(versionlessCpes: Seq[String], versionOption: Option[String])
|
||||
|
||||
GET /vulnerability/:name controllers.Statistics.vulnerability(name, selector: Option[String])
|
||||
|
||||
@@ -14,12 +14,13 @@ play.crypto.secret = "{{ lookup('password', 'play_secret length=64') }}"
|
||||
# ~~~~~
|
||||
play.i18n.langs = [ "en" ]
|
||||
|
||||
play.modules.enabled += "modules.ConfigModule"
|
||||
play.modules.enabled += "modules.SilhouetteModule"
|
||||
|
||||
app.hostname=… # You have to configure the hostname there. If you don't do so, all accesses via hostname will be prohibited. This is a protection against DNS rebind attacks.
|
||||
app{
|
||||
host=… # You have to configure the host there. If you don't do so, all accesses via host will be prohibited. This is a protection against DNS rebind attacks.
|
||||
secure = true # Use true iff you use HTTPS
|
||||
}
|
||||
|
||||
yssdc{
|
||||
cronKey="{{ lookup('cron_token', 'play_secret length=64') }}"
|
||||
bamboo{
|
||||
url = …
|
||||
}
|
||||
@@ -30,6 +31,25 @@ yssdc{
|
||||
password = …
|
||||
}
|
||||
}
|
||||
export{
|
||||
# Optional section: export to issue tracker
|
||||
issueTracker{
|
||||
provider: "jira"
|
||||
server: "http://…"
|
||||
projectId = 10000
|
||||
vulnerabilityIssueType = 10100
|
||||
authentication {
|
||||
type = "credentials"
|
||||
user = "…"
|
||||
password = "…"
|
||||
}
|
||||
}
|
||||
# Optional section: email notifications
|
||||
email{
|
||||
from = "info@example.com"
|
||||
noSubscriberContact = "foobar@example.com"
|
||||
}
|
||||
}
|
||||
projects = {jobId:humanReadableName, …}
|
||||
teams = […]
|
||||
exclusions{
|
||||
@@ -77,7 +97,7 @@ slick.dbs.odc {
|
||||
driver = "slick.driver.MySQLDriver$"
|
||||
db {
|
||||
url = "jdbc:mysql://127.0.0.1/dependencycheck"
|
||||
# Those credentials are default in ODC (but you might have changed them):
|
||||
# These credentials are default in ODC (but you might have changed them):
|
||||
user = "dcuser"
|
||||
password = "DC-Pass1337!"
|
||||
}
|
||||
@@ -113,3 +133,11 @@ silhouette {
|
||||
}
|
||||
}
|
||||
|
||||
play{
|
||||
# needed if you want this app to send emails
|
||||
mailer{
|
||||
//mock = true # If mock is true, mails are not actually sent, but just logged.
|
||||
host = "…"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user