diff --git a/app/assets/css/main.css b/app/assets/css/main.css index cddaead..21f1534 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -184,7 +184,7 @@ h3.library-identification{ content: ""; display: none; } -.dependencies-table .severity .tooltip-inner { +.dependencies-table .severity .tooltip-inner, #library-identifier-wrapper .tooltip-inner { white-space:nowrap; max-width:none; } @@ -242,4 +242,11 @@ h3.library-identification{ .sublist{ padding-left: 23px; +} + +#library-identifier-wrapper .tooltip-inner{ + text-align: left; +} +#scan-results{ + margin-top: 64px; } \ No newline at end of file diff --git a/app/com/ysoft/odc/OdcParser.scala b/app/com/ysoft/odc/OdcParser.scala index 5b28062..76e00ec 100644 --- a/app/com/ysoft/odc/OdcParser.scala +++ b/app/com/ysoft/odc/OdcParser.scala @@ -59,8 +59,9 @@ final case class Dependency( license: String, vulnerabilities: Seq[Vulnerability], suppressedVulnerabilities: Seq[Vulnerability], - relatedDependencies: SerializableXml + relatedDependencies: Seq[RelatedDependency] ){ + def hashes = Hashes(sha1 = sha1, md5 = md5) def plainLibraryIdentifiers: Set[PlainLibraryIdentifier] = identifiers.flatMap(_.toLibraryIdentifierOption).toSet @@ -70,6 +71,20 @@ final case class Dependency( Method equals seems to be a CPU hog there. I am not sure if we can do something reasonable about it. We can compare by this.hashes, but, in such case, dependencies that differ in evidence will be considered the same if their JAR hashes are the same, which would break some sanity checks. */ + +} +final case class RelatedDependency( + fileName: String, + filePath: String, + md5: String, + sha1: String, + description: String, + identifiers: Seq[Identifier], + suppressedIdentifiers: Seq[Identifier], + license: String, + vulnerabilities: Seq[Vulnerability], + suppressedVulnerabilities: Seq[Vulnerability] +){ } /** @@ -189,6 +204,7 @@ object OdcParser { private val vulnPool = new ObjectPool() private val evidencePool = new ObjectPool() + private val relatedDependencyPool = new ObjectPool() private val dependencyPool = new ObjectPool() private val identifierPool = new ObjectPool() private val vulnerableSoftwarePool = new ObjectPool() @@ -285,7 +301,7 @@ object OdcParser { )) } - def parseIdentifier(node: Node, expectedLabel: String): Identifier = { + def parseIdentifier(node: Node, expectedLabel: String, parseConfidence: Boolean = true): Identifier = { if(node.label != expectedLabel){ sys.error("Unexpected label for identifier: "+node.label) } @@ -298,7 +314,7 @@ object OdcParser { }, url = (node \ "url").text, identifierType = node.attribute("type").get.text, - confidence = Confidence.withName(node.attribute("confidence").get.text) + confidence = if(parseConfidence) Confidence.withName(node.attribute("confidence").get.text) else Confidence.Medium )) } @@ -319,7 +335,25 @@ object OdcParser { license = (node \ "license").text, vulnerabilities = vulnerabilities.map(parseVulnerability(_)), suppressedVulnerabilities = suppressedVulnerabilities.map(parseVulnerability(_, "suppressedVulnerability")), - relatedDependencies = SerializableXml(node \ "relatedDependencies") + relatedDependencies = (node \ "relatedDependencies" \ "relatedDependency").map(parseRelatedDependency) + )) + } + + def parseRelatedDependency(node: Node): RelatedDependency = { + checkElements(node, Set("fileName", "filePath", "md5", "sha1", "description", "evidenceCollected", "identifier", "license", "vulnerabilities", "relatedDependencies")) + checkParams(node, Set()) + val (vulnerabilities: Seq[Node], suppressedVulnerabilities: Seq[Node]) = (node \ "vulnerabilities").headOption.map(filterWhitespace).getOrElse(Seq()).partition(_.label == "vulnerability") + relatedDependencyPool(RelatedDependency( + fileName = (node \ "fileName").text, + filePath = (node \ "filePath").text, + md5 = (node \ "md5").text, + sha1 = (node \ "sha1").text, + description = (node \ "description").text, + identifiers = (node \ "identifier").map(parseIdentifier(_, "identifier", parseConfidence = false)), + suppressedIdentifiers = (node \ "suppressedIdentifier").map(parseIdentifier(_, "suppressedIdentifier", parseConfidence = false)), + license = (node \ "license").text, + vulnerabilities = vulnerabilities.map(parseVulnerability(_)), + suppressedVulnerabilities = suppressedVulnerabilities.map(parseVulnerability(_, "suppressedVulnerability")) )) } diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala index 76a079b..b6a74ff 100644 --- a/app/controllers/Application.scala +++ b/app/controllers/Application.scala @@ -285,7 +285,8 @@ class Application @Inject() ( Ok( JavaScriptReverseRouter("Routes")( routes.javascript.Application.setClassified, - routes.javascript.Application.addTag + routes.javascript.Application.addTag, + routes.javascript.LibraryAdvisor.scan ) ).as("text/javascript") } diff --git a/app/controllers/DependencyCheckReportsParser.scala b/app/controllers/DependencyCheckReportsParser.scala index 881993a..579ee22 100644 --- a/app/controllers/DependencyCheckReportsParser.scala +++ b/app/controllers/DependencyCheckReportsParser.scala @@ -113,6 +113,7 @@ private final case class BadFilter(pattern: String) extends Filter{ } object DependencyCheckReportsParser{ + def forAdHocScan(analysis: Analysis): Result = Result(Map(ReportInfo("adHocScan", "Ad hoc scan", "AHS", None) -> analysis), Map(), new ProjectsWithReports(new Projects(Map(), Map(), Map()), Set()), Map()) final case class ResultWithSelection(result: Result, projectsWithSelection: ProjectsWithSelection) final case class Result(bareFlatReports: Map[ReportInfo, Analysis], bareFailedAnalysises: Map[ReportInfo, Throwable], projectsReportInfo: ProjectsWithReports/*TODO: maybe rename to rootProjects*/, failedReportDownloads: Map[ReportInfo, Throwable]){ //lazy val projectsReportInfo = new ProjectsWithReports(projects, (bareFlatReports.keySet ++ bareFailedAnalysises.keySet ++ failedReportDownloads.keySet).map(_.fullId)) // TODO: consider renaming to projectsWithReports @@ -126,6 +127,7 @@ object DependencyCheckReportsParser{ groupedDependencies.toSet.flatMap((grDep: GroupedDependency) => grDep.plainLibraryIdentifiers.map(_ -> grDep)).groupBy(_._1).mapValues(_.map(_._2)).map(identity) lazy val groupedDependenciesByHashes: Map[Hashes, GroupedDependency] = groupedDependencies.map(gd => gd.hashes -> gd).toMap lazy val vulnerableDependencies = groupedDependencies.filter(_.vulnerabilities.nonEmpty) + lazy val nonVulnerableDependencies = groupedDependencies.filter(_.vulnerabilities.isEmpty) lazy val suppressedOnlyDependencies = groupedDependencies.filter(gd => gd.vulnerabilities.isEmpty && gd.suppressedIdentifiers.nonEmpty) private val ProjectSelectorPattern = """^project:(.*)$""".r diff --git a/app/controllers/LibraryAdvisor.scala b/app/controllers/LibraryAdvisor.scala new file mode 100644 index 0000000..e7c0644 --- /dev/null +++ b/app/controllers/LibraryAdvisor.scala @@ -0,0 +1,114 @@ +package controllers + +import java.net.URL +import javax.inject.Inject + +import com.github.nscala_time.time.Imports._ +import com.ysoft.odc.SecureXml +import modules.TemplateCustomization +import org.joda.time.DateTime +import play.api.Configuration +import play.api.i18n.MessagesApi +import play.api.mvc.{Action, AnyContent, Result} +import play.twirl.api.Html +import services.{DependencyNotFoundException, OdcDbService, OdcService, SingleLibraryScanResult} +import views.html.DefaultRequest + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +class LibraryAdvisor @Inject() ( + config: Configuration, + odcServiceOption: Option[OdcService], + odcDbService: OdcDbService, + val messagesApi: MessagesApi, + val env: AuthEnv, + val templateCustomization: TemplateCustomization + ) (implicit ec: ExecutionContext) extends AuthenticatedController +{ + + import secureRequestConversion._ + + private def withOdc(f: OdcService => Future[Result])(implicit defaultRequest: DefaultRequest) = { + odcServiceOption.fold(Future.successful(InternalServerError(views.html.libraryAdvisor.notEnabled())))(odcService => + f(odcService) + ) + } + + private val InputParsers = Seq[(OdcService, String) => Option[Either[Future[SingleLibraryScanResult], String]]]( + (odcService, xmlString) => { + val triedElem = Try { + SecureXml.loadString(xmlString) + } + triedElem.toOption.map{ xml => + xml.label match { + case "dependency" => + /* + Maven POM, e.g.: + + com.google.code.gson + gson + 2.3.1 + + */ + val groupId = (xml \ "groupId").text + val artifactId = (xml \ "artifactId").text + val version = (xml \ "version").text + Left(odcService.scanMaven(groupId, artifactId, version)) + case other => + Right(s"Unknown root XML element: $other") + } + } + }, + (odcService, urlString) => { + Try{new URL(urlString)}.toOption.map{url => + url.getHost match { + case "www.mvnrepository.com" | "mvnrepository.com" => + // https://www.mvnrepository.com/artifact/ch.qos.logback/logback-classic/0.9.10 + // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic/0.9.10 + url.getPath.split('/') match { + case Array("", "artifact", groupId, artifactId, version) => + Left(odcService.scanMaven(groupId, artifactId, version)) + case _ => + Right("Unknown path for mvnrepository.com: Expected https://mvnrepository.com/artifact///") + } + } + } + } + ) + + def index(dependency: Option[String]): Action[AnyContent] = ReadAction.async{ implicit req => + withOdc{ odcService => + Future.successful(Ok(views.html.libraryAdvisor.scanLibrary(dependency, Seq( + Html("<dependency>…</dependency> – Maven POM format"), + Html("https://mvnrepository.com/artifact/groupId/artifactId/version") + )))) + } + } + + //noinspection TypeAnnotation + def scan() = ReadAction.async(parse.json[String]){ implicit req => + withOdc{ odcService => + val now = DateTime.now() + + val oldDataThreshold = 2.days + val lastDbUpdateFuture = odcDbService.loadLastDbUpdate() + val isOldFuture = lastDbUpdateFuture.map{ lastUpdate => now - oldDataThreshold > lastUpdate} + + val response = InputParsers.toStream.map(_(odcService, req.body)).find(_.nonEmpty).flatten match{ + case None => Future.successful(Ok(views.html.libraryAdvisor.scanInputError("Unknown input format"))) + case Some(Right(message)) => Future.successful(Ok(views.html.libraryAdvisor.scanInputError(s"Unknown input format: $message"))) + case Some(Left(resFuture)) => + for{ + res <- resFuture + isOld <- isOldFuture + } yield Ok(views.html.libraryAdvisor.scanResults(isOld, res)) + } + response.recover{ + case DependencyNotFoundException(dependency) => + NotFound(views.html.libraryAdvisor.notFound(dependency)) + }.map { _.withHeaders("Content-type" -> "text/plain; charset=utf-8")} + } + } + +} diff --git a/app/controllers/Notifications.scala b/app/controllers/Notifications.scala index cf614da..e14afff 100644 --- a/app/controllers/Notifications.scala +++ b/app/controllers/Notifications.scala @@ -26,7 +26,7 @@ class Notifications @Inject()( dependencyCheckReportsParser: DependencyCheckReportsParser, issueTrackerServiceOption: Option[IssueTrackerService], emailExportServiceOption: Option[EmailExportService], - odcService: OdcService, + odcService: OdcDbService, absolutizer: Absolutizer, val env: AuthEnv, val templateCustomization: TemplateCustomization diff --git a/app/controllers/Statistics.scala b/app/controllers/Statistics.scala index d5a2c4e..f1ad0b4 100644 --- a/app/controllers/Statistics.scala +++ b/app/controllers/Statistics.scala @@ -67,7 +67,8 @@ class Statistics @Inject()( dependencyCheckReportsParser: DependencyCheckReportsParser, librariesService: LibrariesService, tagsService: TagsService, - odcService: OdcService, + odcDbService: OdcDbService, + odcServiceOption: Option[OdcService], libraryTagAssignmentsService: LibraryTagAssignmentsService, @Named("missing-GAV-exclusions") missingGAVExclusions: MissingGavExclusions, projects: Projects, @@ -98,14 +99,14 @@ class Statistics @Inject()( }else{ val now = DateTime.now() val oldDataThreshold = 2.days - val lastDbUpdateFuture = odcService.loadLastDbUpdate() + val lastDbUpdateFuture = odcDbService.loadLastDbUpdate() val isOldFuture = lastDbUpdateFuture.map{ lastUpdate => now - oldDataThreshold > lastUpdate} versionOption match { case Some(version) => for { - res1 <- Future.traverse(versionlessCpes) { versionlessCpe => odcService.findRelevantCpes(versionlessCpe, version) } + res1 <- Future.traverse(versionlessCpes) { versionlessCpe => odcDbService.findRelevantCpes(versionlessCpe, version) } vulnIds = res1.flatten.map(_.vulnerabilityId).toSet - vulns <- Future.traverse(vulnIds)(id => odcService.getVulnerabilityDetails(id).map(_.get)) + vulns <- Future.traverse(vulnIds)(id => odcDbService.getVulnerabilityDetails(id).map(_.get)) isOld <- isOldFuture } yield Ok(views.html.statistics.vulnerabilitiesForLibrary( vulnsAndVersionOption = Some((vulns, version)), @@ -213,17 +214,27 @@ class Statistics @Inject()( }// .map(identity) // The .map(identity) materializes lazily mapped Map (because .mapValues is lazy). I am, however, unsure if this is a good idea. Probably not. vulns.get(name).fold{ for{ - vulnOption <- odcService.getVulnerabilityDetails(name) + vulnOption <- odcDbService.getVulnerabilityDetails(name) issueOption <- issueOptionFuture - } yield Ok(views.html.statistics.vulnerabilityNotFound( // TODO: the not found page might be replaced by some page explaining that there is no project affected by that vulnerability - name = name, + } yield vulnOption.fold( + Ok(views.html.statistics.vulnerabilityNotFound( + name = name, + projectsWithSelection = selection.projectsWithSelection, + failedProjects = selection.result.failedProjects, + issueOption = issueOption + )) + )(vuln => Ok(views.html.statistics.vulnerability( projectsWithSelection = selection.projectsWithSelection, failedProjects = selection.result.failedProjects, - issueOption = issueOption - )) + issueOption = issueOption, + vulnerability = vuln, + affectedProjects = Map(), + affectedLibraries = Set(), + vulnerableDependencies = Set() + ))) }{ vulnerableDependencies => for { - vulnOption <- odcService.getVulnerabilityDetails(name) + vulnOption <- odcDbService.getVulnerabilityDetails(name) plainLibs <- librariesService.byPlainLibraryIdentifiers(vulnerableDependencies.flatMap(_.plainLibraryIdentifiers)).map(_.keySet) issueOption <- issueOptionFuture } yield vulnOption.fold{ diff --git a/app/controllers/package.scala b/app/controllers/package.scala index 9f170b8..563085d 100644 --- a/app/controllers/package.scala +++ b/app/controllers/package.scala @@ -1,6 +1,7 @@ import com.mohiva.play.silhouette.api.Environment import com.mohiva.play.silhouette.impl.authenticators.CookieAuthenticator import models.{SnoozeInfo, User} +import play.api.mvc.Call /** * Created by user on 7/15/15. @@ -35,4 +36,30 @@ package object controllers { }*/ def friendlyProjectNameString(reportInfo: ReportInfo) = reportInfo.subprojectNameOption.fold(reportInfo.projectName)(reportInfo.projectName+": "+_) + val severityOrdering: Ordering[GroupedDependency] = Ordering.by((d: GroupedDependency) => ( + d.maxCvssScore.map(-_).getOrElse(0.0), // maximum CVSS score is the king + if(d.maxCvssScore.isEmpty) Some(-d.dependencies.size) else None, // more affected dependencies if no vulnerability has defined severity + -d.vulnerabilities.size, // more vulnerabilities + -d.projects.size, // more affected projects + d.cpeIdentifiers.map(_.toCpeIdentifierOption.get).toSeq.sorted.mkString(" ")) // at least make the order deterministic + ) + + def vulnerableSoftwareSearches(groupedDependency: GroupedDependency): Seq[(Call, String)] = { + val legacySearchOption = groupedDependency.cpeIdentifiers match { + case Seq() => None + case cpeIds => Some( + routes.Statistics.searchVulnerableSoftware( + cpeIds.map(_.name.split(':').take(4).mkString(":")).toSeq, None + ) -> "Search by CPE (legacy option)" + ) + } + val mavenSearches = groupedDependency.mavenIdentifiers.map(_.name).toSeq.sorted.map{mavenIdentifier => + val Array(groupId, artifactId, version) = mavenIdentifier.split(":", 3) + val identifierString = {groupId}{artifactId}{version}.toString() + routes.LibraryAdvisor.index(Some(identifierString)) -> s"Look for Maven dependency $mavenIdentifier" + } + mavenSearches ++ legacySearchOption + + } + } diff --git a/app/modules/ConfigModule.scala b/app/modules/ConfigModule.scala index 181355a..a2e478e 100644 --- a/app/modules/ConfigModule.scala +++ b/app/modules/ConfigModule.scala @@ -6,7 +6,7 @@ import java.nio.file.{Files, Path, Paths} import java.util.concurrent.Executors import akka.util.ClassLoaderObjectInputStream -import com.typesafe.config.{Config, ConfigObject, ConfigValue} +import com.typesafe.config.{Config, ConfigObject} import com.ysoft.odc._ import controllers.api._ import controllers.{MissingGavExclusions, Projects, TeamId, WarningSeverity} @@ -15,9 +15,7 @@ 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.collection.mutable import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration import scala.reflect.ClassTag @@ -86,7 +84,7 @@ class FileCacheApi(path: Path) extends CacheApi{ } -case class TemplateCustomization(brandHtml: Option[String]) +case class TemplateCustomization(brandHtml: Option[String], vulnerableLibraryAdvice: Option[String]) class ConfigModule extends Module { @@ -158,7 +156,7 @@ class ConfigModule extends Module { bind[LogSmellChecks].qualifiedWith("log-smells").toInstance(LogSmellChecks(configuration.underlying.getAs[Map[String, LogSmell]]("yssdc.logSmells").getOrElse(Map()))), bind[Projects].to(parseProjects(configuration)), bind[ApiConfig].to(parseApiConfig(configuration)), - bind[TemplateCustomization].to(TemplateCustomization(configuration.underlying.getAs[String]("app.brand"))) + bind[TemplateCustomization].to(TemplateCustomization(configuration.underlying.getAs[String]("app.brand"), configuration.underlying.getAs[String]("app.vulnerableLibraryAdvice"))) ) ++ 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)))) ++ diff --git a/app/modules/EmailExportModule.scala b/app/modules/EmailExportModule.scala index 4478520..5ccf8da 100644 --- a/app/modules/EmailExportModule.scala +++ b/app/modules/EmailExportModule.scala @@ -8,7 +8,7 @@ import net.ceedubs.ficus.Ficus._ import net.codingwell.scalaguice.ScalaModule import play.api.Configuration import play.api.libs.mailer.MailerClient -import services.{OdcService, EmailExportService, EmailExportType, VulnerabilityNotificationService} +import services.{OdcDbService, EmailExportService, EmailExportType, VulnerabilityNotificationService} import net.ceedubs.ficus.readers.EnumerationReader._ import scala.concurrent.ExecutionContext @@ -22,7 +22,7 @@ class EmailExportModule extends AbstractModule with ScalaModule{ mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, absolutizer: Absolutizer, - odcService: OdcService, + odcService: OdcDbService, @Named("email-sending") emailSendingExecutionContext: ExecutionContext )(implicit executionContext: ExecutionContext): Option[EmailExportService] = { println(s"emailSendingExecutionContext = $emailSendingExecutionContext") diff --git a/app/modules/OdcModule.scala b/app/modules/OdcModule.scala new file mode 100644 index 0000000..f9ac171 --- /dev/null +++ b/app/modules/OdcModule.scala @@ -0,0 +1,33 @@ +package modules + +import com.google.inject.{AbstractModule, Provides} +import net.ceedubs.ficus.Ficus._ +import net.ceedubs.ficus.readers.ArbitraryTypeReader._ +import net.codingwell.scalaguice.ScalaModule +import play.api.{Application, Configuration} +import services.{OdcConfig, OdcDbConnectionConfig, OdcService} + +class OdcModule extends AbstractModule with ScalaModule{ + override def configure(): Unit = {} + + private val Drivers = Map( + "slick.driver.MySQLDriver$" -> "org.mariadb.jdbc.Driver" + ) + + @Provides + def provideOdcServiceOption(conf: Configuration, application: Application): Option[OdcService] = { + lazy val dbConfig = { + val driverClass = Drivers(conf.getString("slick.dbs.odc.driver").get) + val driverJar = Class.forName(driverClass).getProtectionDomain.getCodeSource.getLocation.getPath + OdcDbConnectionConfig( + driverClass = driverClass, + driverJar = driverJar, + url = conf.getString("slick.dbs.odc.db.url").get, + user = conf.getString("slick.dbs.odc.db.user").get, + password = conf.getString("slick.dbs.odc.db.password").get + ) + } + conf.underlying.getAs[OdcConfig]("odc").map(config => new OdcService(config, dbConfig)(application)) + } + +} diff --git a/app/services/DependencyNotFoundException.scala b/app/services/DependencyNotFoundException.scala new file mode 100644 index 0000000..aadcc0d --- /dev/null +++ b/app/services/DependencyNotFoundException.scala @@ -0,0 +1,5 @@ +package services + +case class DependencyNotFoundException(val dependency: String) extends Exception(s"Dependency $dependency is not found"){ + +} diff --git a/app/services/EmailExportService.scala b/app/services/EmailExportService.scala index bef94b2..9a9b9ab 100644 --- a/app/services/EmailExportService.scala +++ b/app/services/EmailExportService.scala @@ -19,7 +19,7 @@ object EmailExportType extends Enumeration { val Digest = Value("digest") } -class EmailExportService(from: String, nobodyInterestedContact: String, val exportType: EmailExportType.Value, odcService: OdcService, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, emailSendingExecutionContext: ExecutionContext, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext) { +class EmailExportService(from: String, nobodyInterestedContact: String, val exportType: EmailExportType.Value, odcService: OdcDbService, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, emailSendingExecutionContext: ExecutionContext, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext) { // Maybe it is not the best place for exportType, but I am not sure if we want this to be configurable. If no, then we can get rid of it. If yes, we should refactor it. diff --git a/app/services/OdcDbService.scala b/app/services/OdcDbService.scala new file mode 100644 index 0000000..1ca7e6b --- /dev/null +++ b/app/services/OdcDbService.scala @@ -0,0 +1,132 @@ +package services + +import java.lang.{Boolean => JBoolean} +import java.sql.{Array => _, _} +import java.util.{Properties, Map => JMap} + +import _root_.org.owasp.dependencycheck.data.nvdcve.CveDB +import _root_.org.owasp.dependencycheck.dependency.{VulnerableSoftware => OdcVulnerableSoftware} +import _root_.org.owasp.dependencycheck.utils.{DependencyVersion, DependencyVersionUtil, Settings} +import com.github.nscala_time.time.Imports._ +import com.google.inject.Inject +import com.mockrunner.mock.jdbc.MockConnection +import models.odc.tables._ +import models.odc.{OdcProperty, Vulnerabilities} +import play.api.Logger +import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} +import play.db.NamedDatabase + +import scala.concurrent.{ExecutionContext, Future} + +class OdcDbService @Inject()(@NamedDatabase("odc") protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends HasDatabaseConfigProvider[models.odc.profile.type]{ + + import dbConfig.driver.api._ + + private def getVulnerableSoftware(id: Int): Future[Seq[com.ysoft.odc.VulnerableSoftware]] = { + db.run(softwareVulnerabilities.joinLeft(cpeEntries).on((sv, ce) => sv.cpeEntryId === ce.id).filter{case (sv, ceo) => sv.vulnerabilityId === id}.result).map{rawRefs => + rawRefs.map{ + case (softVuln, Some((_, cpeEntry))) => com.ysoft.odc.VulnerableSoftware(allPreviousVersion = softVuln.includesAllPreviousVersions, name=cpeEntry.cpe) + } + } + } + + private def getReferences(id: Int): Future[Seq[com.ysoft.odc.Reference]] = db.run(references.filter(_.cveId === id).map(_.base).result) + + def getVulnerabilityDetails(id: Int): Future[Option[com.ysoft.odc.Vulnerability]] = getVulnerabilityDetails(_.id === id) + + def getVulnerabilityDetails(name: String): Future[Option[com.ysoft.odc.Vulnerability]] = getVulnerabilityDetails(_.cve === name) + + private def getVulnerabilityDetails(cond: Vulnerabilities => Rep[Boolean]): Future[Option[com.ysoft.odc.Vulnerability]] = { + db.run(vulnerabilities.filter(cond).result).map(_.headOption) flatMap { bareVulnOption => + bareVulnOption.fold[Future[Option[com.ysoft.odc.Vulnerability]]](Future.successful(None)) { case (id, bareVuln) => + for { + vulnerableSoftware <- getVulnerableSoftware(id) + references <- getReferences(id) + } yield Some( + com.ysoft.odc.Vulnerability( + name = bareVuln.cve, + cweOption = bareVuln.cweOption, + cvss = bareVuln.cvss, + description = bareVuln.description, + vulnerableSoftware = vulnerableSoftware, + references = references + ) + ) + } + } + } + + private def parseCpe(cpe: String) = { + val sw = new OdcVulnerableSoftware() + sw.parseName(cpe) + sw + } + + private def parseVersion(version: String): DependencyVersion = { + DependencyVersionUtil.parseVersion(version) + } + + def findRelevantCpes(versionlessCpe: String, version: String) = { + println(s"versionlessCpe: $versionlessCpe") + val Seq("cpe", "/a", vendor, product, rest @ _*) = versionlessCpe.split(':').toSeq + val cpesFuture = db.run( + cpeEntries.filter(c => + c.vendor === vendor && c.product === product + ).result + ) + for(cpes <- cpesFuture){println(s"cpes: $cpes")} + val cpesMapFuture = cpesFuture.map(_.toMap) + val cpeIdsFuture = cpesFuture.map(_.map(_._1)) + val parsedVersion = parseVersion(version) + val res = for{ + cpeIds <- cpeIdsFuture + relevantVulnerabilities <- db.run( + softwareVulnerabilities.join(vulnerabilities).on( (sv, v) => sv.vulnerabilityId === v.id) + .filter{case (sv, v) => sv.cpeEntryId inSet cpeIds}.map{case (sv, v) ⇒ sv}.result + ).map(_.groupBy(_.vulnerabilityId).mapValues(_.toSet)) + cpesMap <- cpesMapFuture + //relevantVulnerabilities <- db.run(vulnerabilities.filter(_.id inSet relevantVulnerabilityIds).result) + } yield relevantVulnerabilities.filter{case (vulnId, sv) => Option(CveDbHelper.matchSofware( + vulnerableSoftware = sv.map(sv => cpesMap(sv.cpeEntryId).cpe -> sv.includesAllPreviousVersions).toMap, + vendor = vendor, + product = product, + identifiedVersion = parsedVersion + )).isDefined} + res.map(_.values.toSet.flatten) + } + + private def loadUpdateProperties(): Future[Map[String, Long]] = db.run(properties.filter(_.id like "NVD CVE%").result).map(_.map{case OdcProperty(id, value) => (id, value.toLong)}.toMap) + + def loadLastDbUpdate(): Future[DateTime] = loadUpdateProperties().map(vals => new DateTime(vals.values.max)) // TODO: timezone (I don't care much, though) + +} + +private[services] object CveDbHelper { + + class DummyDriver extends Driver{ + override def acceptsURL(url: String): Boolean = {url.startsWith("jdbc:dummy:")} + override def jdbcCompliant(): Boolean = false + override def connect(url: String, info: Properties): Connection = new MockConnection() + override def getParentLogger = throw new SQLFeatureNotSupportedException() + override def getPropertyInfo(url: String, info: Properties): Array[DriverPropertyInfo] = {Array()} + override def getMinorVersion: Int = 1 + override def getMajorVersion: Int = 1 + } + + org.apache.geronimo.jdbc.DelegatingDriver.registerDriver(new DummyDriver()) + + def matchSofware(vulnerableSoftware: Map[String, Boolean], vendor: String, product: String, identifiedVersion: DependencyVersion) = { + if(Settings.getInstance() == null){ + Settings.initialize()// Initiallize ODC environment on first use; Needed for each thread. + Settings.setString(Settings.KEYS.DB_CONNECTION_STRING, "jdbc:dummy:") + // Workaround: At first initialization, it will complain that the DB is empty. On next initializations, it will not complain. + try{new CveDB()}catch {case e: Throwable => Logger.info("A workaround-related exception, safe to ignore", e)} + } + val cd = new CveDB() + import scala.collection.JavaConversions._ + val method = cd.getClass.getDeclaredMethod("getMatchingSoftware", classOf[JMap[String, JBoolean]], classOf[String], classOf[String], classOf[DependencyVersion]) + method.setAccessible(true) + method.invoke(cd, mapAsJavaMap(vulnerableSoftware).asInstanceOf[JMap[String, JBoolean]], vendor, product, identifiedVersion) + } +} + diff --git a/app/services/OdcService.scala b/app/services/OdcService.scala index 16c8d8c..a00638f 100644 --- a/app/services/OdcService.scala +++ b/app/services/OdcService.scala @@ -1,148 +1,245 @@ package services +import java.io.File.separatorChar +import java.io._ import java.lang.{Boolean => JBoolean} -import java.sql.{Array => _, _} +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file._ +import java.nio.file.attribute.BasicFileAttributes +import java.sql.{Array => _} import java.util.{Properties, Map => JMap} -import _root_.org.owasp.dependencycheck.data.nvdcve.CveDB -import _root_.org.owasp.dependencycheck.dependency.VulnerableSoftware -import _root_.org.owasp.dependencycheck.utils.{DependencyVersion, DependencyVersionUtil, Settings} -import com.github.nscala_time.time.Imports._ +import _root_.org.apache.commons.lang3.SystemUtils +import _root_.org.owasp.dependencycheck.dependency.{VulnerableSoftware => OdcVulnerableSoftware} import com.google.inject.Inject -import com.mockrunner.mock.jdbc.MockConnection -import models.odc.tables._ -import models.odc.{OdcProperty, Vulnerabilities} -import play.api.Logger -import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} -import play.db.NamedDatabase +import com.ysoft.odc.{GroupedDependency, Identifier, OdcParser} +import controllers.DependencyCheckReportsParser +import play.api.Application +import play.api.libs.concurrent.Akka import scala.concurrent.{ExecutionContext, Future} -class OdcService @Inject()(@NamedDatabase("odc") protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends HasDatabaseConfigProvider[models.odc.profile.type]{ +case class OdcDbConnectionConfig(driverClass: String, driverJar: String, url: String, user: String, password: String) - import dbConfig.driver.api._ +case class OdcConfig(odcPath: String, extraArgs: Seq[String] = Seq(), workingDirectory: String = ".", propertyFile: Option[String]) - def getVulnerableSoftware(id: Int): Future[Seq[com.ysoft.odc.VulnerableSoftware]] = { - db.run(softwareVulnerabilities.joinLeft(cpeEntries).on((sv, ce) => sv.cpeEntryId === ce.id).filter{case (sv, ceo) => sv.vulnerabilityId === id}.result).map{rawRefs => - rawRefs.map{ - case (softVuln, Some((_, cpeEntry))) => com.ysoft.odc.VulnerableSoftware(allPreviousVersion = softVuln.includesAllPreviousVersions, name=cpeEntry.cpe) +case class SingleLibraryScanResult(mainDependency: GroupedDependency, transitiveDependencies: Seq[GroupedDependency], includesTransitive: Boolean) + +class OdcService @Inject() (odcConfig: OdcConfig, odcDbConnectionConfig: OdcDbConnectionConfig)(implicit application: Application){ + private implicit val executionContext: ExecutionContext = Akka.system.dispatchers.lookup("contexts.odc-workers") + private def suffix = if(SystemUtils.IS_OS_WINDOWS) "bat" else "sh" + private def odcBin = new File(new File(odcConfig.odcPath), "bin"+separatorChar+"dependency-check."+suffix).getAbsolutePath + private def mavenBin = "mvn" + private val OutputFormat = "XML" + private val DependencyNotFoundPrefix = "[ERROR] Failed to execute goal on project odc-adhoc-project: Could not resolve dependencies for project com.ysoft:odc-adhoc-project:jar:1.0-SNAPSHOT: Could not find artifact " + + private def mavenLogChecks(log: String) = { + if(log.lines contains "[INFO] No dependencies were identified that could be analyzed by dependency-check"){ + sys.error("Dependency not identified. Log: "+log) + } + for(missingDependencyMessage <- log.lines.find(_.startsWith(DependencyNotFoundPrefix))){ + val missingDependency = missingDependencyMessage.drop(DependencyNotFoundPrefix.length).takeWhile(_ != ' ') + throw DependencyNotFoundException(missingDependency) + } + } + + def scanMaven(groupId: String, artifactId: String, version: String): Future[SingleLibraryScanResult] = scanInternal( + createOdcCommand = createMavenOdcCommand, + isMainLibraryOption = Some(_.exists(id => id.identifierType == "maven" && id.name == s"$groupId:$artifactId:$version")), + logChecks = mavenLogChecks + ){ dir => + val pomXml = + 4.0.0 + com.ysoft + odc-adhoc-project + 1.0-SNAPSHOT + + + + org.owasp + dependency-check-maven + + {"${outputDirectory}"} + + + + + check + + + + + + + + + {groupId} + {artifactId} + {version} + + + + Files.write(dir.resolve("pom.xml"), pomXml.toString.getBytes(UTF_8)) + } + + private def consumeStream(in: InputStream): Array[Byte] = { + val baos = new ByteArrayOutputStream() + val buff = new Array[Byte](1024) + var size: Int = 0 + while({size = in.read(buff); size != -1}) { + baos.write(buff, 0, size) + } + baos.toByteArray + } + + private def scanInternal( + createOdcCommand: (String, Path, String) => Seq[String] = createStandardOdcCommand, + isMainLibraryOption: Option[Seq[Identifier] => Boolean], + logChecks: String => Unit = s => () + )( + f: Path => Unit + ): Future[SingleLibraryScanResult] = Future{ + withTmpDir { scanDir => + val scandirPrefix = s"$scanDir$separatorChar" + val reportFilename = s"${scandirPrefix}report.xml" + val path = scanDir.resolve("scanned-dir") + Files.createDirectory(path) + f(path) + val cmd: Seq[String] = createOdcCommand(scandirPrefix, path, reportFilename) + val process = new ProcessBuilder(cmd: _*). + directory(new File(odcConfig.workingDirectory)). + redirectErrorStream(true). + start() + val in = process.getInputStream + // We consume all output in order not to hang; we mix stderr and stdout together + val outArray = consumeStream(in) + val res = process.waitFor() + lazy val log = new String(outArray) + logChecks(log) + if(res != 0){ + sys.error(s"Non-zero return value: $res; output: $log") + } + val result = DependencyCheckReportsParser.forAdHocScan(OdcParser.parseXmlReport(Files.readAllBytes(Paths.get(reportFilename)))) + val (Seq(mainLibrary), otherLibraries) = result.allDependencies.partition{case (dep, _) => isMainLibraryOption.fold(true)(f => f(dep.identifiers) || dep.relatedDependencies.map(_.identifiers).exists(f))} + SingleLibraryScanResult( + mainDependency = GroupedDependency(Seq(mainLibrary)), + transitiveDependencies = otherLibraries.map(dep => GroupedDependency(Seq(dep))), + includesTransitive = isMainLibraryOption.isDefined + ) + } + } + + private def odcVersion: String = { + import sys.process._ + Seq(odcBin, "--version").!!.trim.reverse.takeWhile(_!=' ').reverse + } + + private def createHintfulOdcCommand(scandirPrefix: String, path: Path, reportFilename: String): Seq[String] = { + val newPropertyFile = s"${scandirPrefix}odc.properties" + val p = new Properties() + for(origPropFile <- odcConfig.propertyFile){ + val in = new FileInputStream(Paths.get(odcConfig.workingDirectory).resolve(origPropFile).toFile) + try{ + p.load(in) + }finally{ + in.close() } } - } - - def getReferences(id: Int): Future[Seq[com.ysoft.odc.Reference]] = db.run(references.filter(_.cveId === id).map(_.base).result) - - def getVulnerabilityDetails(id: Int): Future[Option[com.ysoft.odc.Vulnerability]] = getVulnerabilityDetails(_.id === id) - - def getVulnerabilityDetails(name: String): Future[Option[com.ysoft.odc.Vulnerability]] = getVulnerabilityDetails(_.cve === name) - - def getVulnerabilityDetails(cond: Vulnerabilities => Rep[Boolean]): Future[Option[com.ysoft.odc.Vulnerability]] = { - db.run(vulnerabilities.filter(cond).result).map(_.headOption) flatMap { bareVulnOption => - bareVulnOption.fold[Future[Option[com.ysoft.odc.Vulnerability]]](Future.successful(None)) { case (id, bareVuln) => - for { - vulnerableSoftware <- getVulnerableSoftware(id) - references <- getReferences(id) - } yield Some( - com.ysoft.odc.Vulnerability( - name = bareVuln.cve, - cweOption = bareVuln.cweOption, - cvss = bareVuln.cvss, - description = bareVuln.description, - vulnerableSoftware = vulnerableSoftware, - references = references - ) - ) - } - } - } - - private def parseCpe(cpe: String) = { - val sw = new VulnerableSoftware() - sw.parseName(cpe) - sw - } - - def parseVersion(version: String): DependencyVersion = { - DependencyVersionUtil.parseVersion(version) - } - - /*def parseCpeVersion(cpe: String): DependencyVersion = { // strongly inspired by org.owasp.dependencycheck.data.nvdcve.CveDB.parseDependencyVersion(cpe: VulnerableSoftware): DependencyVersion - def StringOption(s: String) = Option(s).filterNot(_.isEmpty) - val sw = parseCpe(cpe) - StringOption(sw.getVersion) match { - case None ⇒ new DependencyVersion("-") - case Some(bareVersionString) ⇒ - DependencyVersionUtil.parseVersion( - StringOption(sw.getUpdate) match { - case None ⇒ bareVersionString - case Some(update) ⇒ s"$bareVersionString.$update" - } - ) - } - }*/ - - def findRelevantCpes(versionlessCpe: String, version: String) = { - println(s"versionlessCpe: $versionlessCpe") - val Seq("cpe", "/a", vendor, product, rest @ _*) = versionlessCpe.split(':').toSeq - val cpesFuture = db.run( - cpeEntries.filter(c => - c.vendor === vendor && c.product === product - ).result - ) - for(cpes <- cpesFuture){println(s"cpes: $cpes")} - val cpesMapFuture = cpesFuture.map(_.toMap) - val cpeIdsFuture = cpesFuture.map(_.map(_._1)) - val parsedVersion = parseVersion(version) - val res = for{ - cpeIds <- cpeIdsFuture - relevantVulnerabilities <- db.run( - softwareVulnerabilities.join(vulnerabilities).on( (sv, v) => sv.vulnerabilityId === v.id) - .filter{case (sv, v) => sv.cpeEntryId inSet cpeIds}.map{case (sv, v) ⇒ sv}.result - ).map(_.groupBy(_.vulnerabilityId).mapValues(_.toSet)) - cpesMap <- cpesMapFuture - //relevantVulnerabilities <- db.run(vulnerabilities.filter(_.id inSet relevantVulnerabilityIds).result) - } yield relevantVulnerabilities.filter{case (vulnId, sv) => Option(CveDbHelper.matchSofware( - vulnerableSoftware = sv.map(sv => cpesMap(sv.cpeEntryId).cpe -> sv.includesAllPreviousVersions).toMap, - vendor = vendor, - product = product, - identifiedVersion = parsedVersion - )).isDefined} - res.map(_.values.toSet.flatten) - } - - def loadUpdateProperties(): Future[Map[String, Long]] = db.run(properties.filter(_.id like "NVD CVE%").result).map(_.map{case OdcProperty(id, value) => (id, value.toLong)}.toMap) - - def loadLastDbUpdate(): Future[DateTime] = loadUpdateProperties().map(vals => new DateTime(vals.values.max)) // TODO: timezone (I don't care much, though) - -} - - -private[services] object CveDbHelper { - - class DummyDriver extends Driver{ - override def acceptsURL(url: String): Boolean = {url.startsWith("jdbc:dummy:")} - override def jdbcCompliant(): Boolean = false - override def connect(url: String, info: Properties): Connection = new MockConnection() - override def getParentLogger = throw new SQLFeatureNotSupportedException() - override def getPropertyInfo(url: String, info: Properties): Array[DriverPropertyInfo] = {Array()} - override def getMinorVersion: Int = 1 - override def getMajorVersion: Int = 1 - } - - org.apache.geronimo.jdbc.DelegatingDriver.registerDriver(new DummyDriver()) - - def matchSofware(vulnerableSoftware: Map[String, Boolean], vendor: String, product: String, identifiedVersion: DependencyVersion) = { - if(Settings.getInstance() == null){ - Settings.initialize()// Initiallize ODC environment on first use; Needed for each thread. - Settings.setString(Settings.KEYS.DB_CONNECTION_STRING, "jdbc:dummy:") - // Workaround: At first initialization, it will complain that the DB is empty. On next initializations, it will not complain. - try{new CveDB()}catch {case e: Throwable => Logger.info("A workaround-related exception, safe to ignore", e)} - } - val cd = new CveDB() import scala.collection.JavaConversions._ - val method = cd.getClass.getDeclaredMethod("getMatchingSoftware", classOf[JMap[String, JBoolean]], classOf[String], classOf[String], classOf[DependencyVersion]) - method.setAccessible(true) - method.invoke(cd, mapAsJavaMap(vulnerableSoftware).asInstanceOf[JMap[String, JBoolean]], vendor, product, identifiedVersion) + p.put("hints.file", s"${scandirPrefix}hints.xml") + p.putAll(dbProps) + val out = new FileOutputStream(Paths.get(newPropertyFile).toFile) + try{ + p.store(out, "no comment") + }finally { + out.close() + } + val cmdBase = Seq( + odcBin, + "-s", path.toString, + "--project", "AdHocProject", + "--noupdate", + "-f", OutputFormat, + "-l", s"${scandirPrefix}verbose.log", + "--out", reportFilename, + "-P", newPropertyFile + ) + cmdBase ++ odcConfig.extraArgs } -} + private def createStandardOdcCommand(scandirPrefix: String, path: Path, reportFilename: String): Seq[String] = { + val cmdBase = Seq( + odcBin, + "-s", path.toString, + "--project", "AdHocProject", + "--noupdate", + "-f", OutputFormat, + "-l", s"${scandirPrefix}verbose.log", + "--out", reportFilename + ) ++ odcConfig.propertyFile.fold(Seq[String]())(Seq("-P", _)) + cmdBase ++ odcConfig.extraArgs + } + + private def createMavenOdcCommand(scandirPrefix: String, path: Path, reportFilename: String): Seq[String] = { + val cmdBase = Seq( + mavenBin, + "--file", s"${path}${separatorChar}pom.xml", + "-X", + "-U", // force update + s"org.owasp:dependency-check-maven:$odcVersion:check", + "-Dautoupdate=false", + s"-Dformat=$OutputFormat", + s"-DlogFile=${scandirPrefix}verbose.log", + s"-DoutputDirectory=$reportFilename" + ) + cmdBase ++ propsArgs ++ propsToArgs(dbProps) // TODO: fix credentials leak via /proc + } + + private def dbProps = Map( + "data.driver_path" -> odcDbConnectionConfig.driverJar, + "data.driver_name" -> odcDbConnectionConfig.driverClass, + "data.connection_string" -> odcDbConnectionConfig.url, + "data.user" -> odcDbConnectionConfig.user, + "data.password" -> odcDbConnectionConfig.password + ) + + private def propsToArgs(props: Traversable[(String, String)]): Traversable[String] = for((key, value) <- props) yield s"-D$key=$value" + + private def propsArgs = odcConfig.propertyFile.fold(Seq[String]()){ propertyFile => + val props = new Properties() + val in = new FileInputStream(Paths.get(odcConfig.workingDirectory).resolve(propertyFile).toFile) + try { + props.load(in) + } finally { + in.close() + } + import scala.collection.JavaConversions._ + propsToArgs(props.toSeq).toSeq + } + + + private def withTmpDir[T](f: Path => T): T = { + val tmpDir = Files.createTempDirectory("odc") + try { + f(tmpDir) + } finally { + rmdir(tmpDir) + } + } + + private def rmdir(dir: Path) = { + Files.walkFileTree(dir, new SimpleFileVisitor[Path] { + override def visitFile(f: Path, basicFileAttributes: BasicFileAttributes): FileVisitResult = { + Files.delete(f) + FileVisitResult.CONTINUE + } + + override def postVisitDirectory(dir: Path, e: IOException): FileVisitResult = { + Files.delete(dir) + FileVisitResult.CONTINUE + } + }) + } + + override def toString = s"OdcService($odcConfig, $executionContext)" +} diff --git a/app/views/dependencyDetailsInner.scala.html b/app/views/dependencyDetailsInner.scala.html index 38f1948..831df9a 100644 --- a/app/views/dependencyDetailsInner.scala.html +++ b/app/views/dependencyDetailsInner.scala.html @@ -1,13 +1,25 @@ -@(depPrefix: String, dep: GroupedDependency, selectorOption: Option[String]) +@(depPrefix: String, dep: GroupedDependency, selectorOption: Option[String], showAffectedProjects: Boolean = false, expandVulnerabilities: Boolean = false, vulnerabilitySearch: Boolean = true) -@dep.cpeIdentifiers.toSeq match { - case Seq() => {} - case cpeIds => { -

- Look for vulnerabilities in other versions -

+@if(vulnerabilitySearch){ + @vulnerableSoftwareSearches(dep) match { + case Seq() => {} + case Seq((link, description)) => { +

+ Look for vulnerabilities in other versions +

+ } + case options => { +

+

+

+ } } } @@ -62,30 +74,32 @@ } - -
-
    - @for(p <- dep.projects.toIndexedSeq.sorted){ -
  • @friendlyProjectName(p)
  • +@if(showAffectedProjects){ + +
    +
      + @for(p <- dep.projects.toIndexedSeq.sorted){ +
    • @friendlyProjectName(p)
    • + } +
    + @if(selectorOption.isDefined){ + +
    } -
- @if(selectorOption.isDefined){ - -
- } -
+ +}
    @for(vuln <- dep.vulnerabilities.toSeq.sortBy(_.cvssScore.map(-_)); vulnPrefix = s"$depPrefix-vulnerabilities-details-${vuln.name}"){
  • -
    @vuln.name @if(vuln.likelyMatchesOnlyWithoutVersion(dep.identifiers)){}
    -
    +
    @vulnerability("h6", depPrefix+"-"+vuln.name, vuln)

    Full details about this vulnerability

    diff --git a/app/views/dependencyList.scala.html b/app/views/dependencyList.scala.html index aa888a2..40bbd6c 100644 --- a/app/views/dependencyList.scala.html +++ b/app/views/dependencyList.scala.html @@ -1,4 +1,4 @@ -@(idPrefix: String, list: Seq[GroupedDependency], selectorOption: Option[String], expandByDefault: Boolean = true, addButtons: Boolean = true) +@(idPrefix: String, list: Seq[GroupedDependency], selectorOption: Option[String], lazyLoad: Boolean = true, expand: GroupedDependency => Boolean = _ => false, addButtons: Boolean = true, showAffectedProjects: Boolean = true, expandVulnerabilities: Boolean = false, vulnerabilitySearch: Boolean = true) @cpeHtmlId(cpe: String) = @{ cpe.getBytes("utf-8").mkString("-") } @@ -15,11 +15,15 @@ @for(dep <- list; depPrefix = s"$idPrefix-${dep.hashes.serialized}"){ - @for(s <- dep.maxCvssScore) { + @dep.maxCvssScore.fold{ + OK + }{ s => @s - - affects @dep.projects.size @if(dep.projects.size>1){projects}else{project} - + @if(showAffectedProjects){ + + affects @dep.projects.size @if(dep.projects.size>1){projects}else{project} + + } } @@ -27,14 +31,18 @@ @for(s <- dep.maxCvssScore) {@dep.vulnerabilities.size} - + - " id="@depPrefix-details" class="details collapse@if(expand(dep)){ in}" @if(lazyLoad){data-lazyload-url="@routes.Statistics.dependencyDetails( depPrefix = depPrefix, depId = dep.hashes, selectorOption = selectorOption - )"> + )"}> + @if(!lazyLoad){ + @dependencyDetailsInner(depPrefix = depPrefix, dep = dep, selectorOption = selectorOption, showAffectedProjects = showAffectedProjects, expandVulnerabilities = expandVulnerabilities, vulnerabilitySearch = vulnerabilitySearch) + } + } +
    This tool helps you with selecting a new libraries (or with choosing the right library version for update) by automating a boring part of the process: It can look for known vulnerabilities.
    +
    +
    + +
    + + + +
    + +
    +} \ No newline at end of file diff --git a/app/views/libraryAdvisor/scanResults.scala.html b/app/views/libraryAdvisor/scanResults.scala.html new file mode 100644 index 0000000..a44f489 --- /dev/null +++ b/app/views/libraryAdvisor/scanResults.scala.html @@ -0,0 +1,35 @@ +@import services.SingleLibraryScanResult +@(isDbOld: Boolean, singleLibraryScanResult: SingleLibraryScanResult)(implicit header: DefaultRequest, mainTemplateData: MainTemplateData) +@import singleLibraryScanResult.{transitiveDependencies, includesTransitive, mainDependency} +

    Overall result

    +@vulnerableTransitive = @{transitiveDependencies.exists(_.isVulnerable)} +@vulnerableMain = @{mainDependency.isVulnerable} +@if(isDbOld){ +
    The vulnerability database seems to be outdated. Result might be thus inaccurate. Contact the administrator, please.
    +} +@(vulnerableMain, vulnerableTransitive) match { + case (false, false) => { +
    No vulnerability has been found in the library@if(includesTransitive){ or in its transitive dependencies}.
    + } + case (false, true) => {
    While there is no vulnerability found in the library itself, but scan has identified some issues in its transitive dependencies. Maybe you should evict some dependency with a fixed version. @vulnerabilityAdvice()
    } + case (true, false) => {
    There is a vulnerability found in the main dependency. Transitive dependencies are OK. Please consider using a patched version or consider impact of the vulnerabilities. @vulnerabilityAdvice()
    } + case (true, true) => {
    There is a vulnerability found in both the main dependency and transitive dependencies. Please consider using a patched version or consider impact of the vulnerabilities. @vulnerabilityAdvice()
    } +} +@if(!includesTransitive){ +
    This type of scan does not scan transitive dependencies.
    +} +

    The library itself

    +@dependencyList("id", Seq(mainDependency), None, expand = _.isVulnerable, addButtons = false, lazyLoad = false, showAffectedProjects = false, expandVulnerabilities = true, vulnerabilitySearch = false) +@if(includesTransitive) { +

    Transitive dependencies

    + @if(transitiveDependencies.nonEmpty) { + @if(vulnerableTransitive){ +
    Those vulnerabilities are primarily sorted by highest-rated known vulnerability. Transitive dependencies without a known vulnerability are at the end of the list.
    + }else{ +
    There is no known vulnerability in transitive dependencies. They are listed just for your information.
    + } + @dependencyList("id", transitiveDependencies.sorted(severityOrdering), None, expand = _.isVulnerable, addButtons = false, lazyLoad = false, showAffectedProjects = false, expandVulnerabilities = true, vulnerabilitySearch = true) + }else{ + This library has no transitive dependencies. + } +} diff --git a/app/views/libraryAdvisor/vulnerabilityAdvice.scala.html b/app/views/libraryAdvisor/vulnerabilityAdvice.scala.html new file mode 100644 index 0000000..9b7855f --- /dev/null +++ b/app/views/libraryAdvisor/vulnerabilityAdvice.scala.html @@ -0,0 +1,4 @@ +@()(implicit mainTemplateData: MainTemplateData) +@for(msg <- mainTemplateData.templateCustomization.vulnerableLibraryAdvice){ + @msg +} diff --git a/app/views/main.scala.html b/app/views/main.scala.html index c95dca2..8880cac 100644 --- a/app/views/main.scala.html +++ b/app/views/main.scala.html @@ -47,6 +47,7 @@ @filter = @{projectsOption.flatMap(_._1.selectorString)}
  • Vulnerable libraries
  • Vulnerabilities
  • +
  • Scan library
  • Notifications
  • Status
  • diff --git a/app/views/statistics/allLibraries.scala.html b/app/views/statistics/allLibraries.scala.html index 49c87d6..524d61c 100644 --- a/app/views/statistics/allLibraries.scala.html +++ b/app/views/statistics/allLibraries.scala.html @@ -14,7 +14,6 @@ "all", allDependencies.sortBy(_.identifiers.toIndexedSeq.sortBy(i => (i.confidence.id, i.identifierType, i.name)).mkString(", ")), selectorOption = projectsWithSelection.selectorString, - expandByDefault = false, addButtons = false ) diff --git a/app/views/statistics/vulnerableLibraries.scala.html b/app/views/statistics/vulnerableLibraries.scala.html index 7c7c31c..45ac197 100644 --- a/app/views/statistics/vulnerableLibraries.scala.html +++ b/app/views/statistics/vulnerableLibraries.scala.html @@ -62,15 +62,8 @@ $(document).ready(function(){ @dependencyList( "vulnerable", - vulnerableDependencies.sortBy(d => ( - d.maxCvssScore.map(-_), // maximum CVSS score is the king - if(d.maxCvssScore.isEmpty) Some(-d.dependencies.size) else None, // more affected dependencies if no vulnerability has defined severity - -d.vulnerabilities.size, // more vulnerabilities - -d.projects.size, // more affected projects - d.cpeIdentifiers.map(_.toCpeIdentifierOption.get).toSeq.sorted.mkString(" ")) // at least make the order deterministic - ), + vulnerableDependencies.sorted(severityOrdering), selectorOption = projectsWithSelection.selectorString, - expandByDefault = false, addButtons = false ) } \ No newline at end of file diff --git a/build.sbt b/build.sbt index 2c6f8f1..1f5d99d 100644 --- a/build.sbt +++ b/build.sbt @@ -75,7 +75,7 @@ libraryDependencies += "net.codingwell" %% "scala-guice" % "4.0.0" libraryDependencies += "com.iheart" %% "ficus" % "1.4.0" -libraryDependencies += "org.owasp" % "dependency-check-core" % "1.4.2" +libraryDependencies += "org.owasp" % "dependency-check-core" % "1.4.5" libraryDependencies += "com.typesafe.play" %% "play-mailer" % "3.0.1" diff --git a/conf/application.conf.-example b/conf/application.conf.-example index a08f6dc..bda0d2b 100644 --- a/conf/application.conf.-example +++ b/conf/application.conf.-example @@ -19,6 +19,8 @@ play.i18n.langs = [ "en" ] app{ host = "localhost" # 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 = false # Use true iff you use HTTPS + # brand = "Your brand" # optional + # vulnerableLibraryAdvice = "If in doubt, contact our security team." # optional } yssdc{ @@ -141,6 +143,15 @@ slick.dbs.odc { #play.modules.disabled+="play.api.cache.EhCacheModule" #play.cache.path = "/home/user/.cache/odc-analysis" +## [Optional] Path to OWASP Dependency Check +## Once you configure it, you enable some checking features. You also need Maven on PATH. +## (!) Note that some properties like DB credentials might be passed as arguments and thus available via /proc (depends on OS). +# odc { +# odcPath = "/path/to/dependency-check-X.Y.Z-release" +# workingDirectory = "/path/to/odc/config" # directory ODC works in; you can use relative paths from this directory +# propertyFile = "odc.props" # path to ODC property file +# extraArgs = [] # Unstable conf; This might be changed or removed without any notice!!! +# } silhouette { # Authenticator settings diff --git a/conf/reference.conf b/conf/reference.conf index fd4be5c..2bdc49a 100644 --- a/conf/reference.conf +++ b/conf/reference.conf @@ -2,3 +2,4 @@ play.modules.enabled += "modules.ConfigModule" play.modules.enabled += "modules.SilhouetteModule" play.modules.enabled += "modules.IssueTrackerExportModule" play.modules.enabled += "modules.EmailExportModule" +play.modules.enabled += "modules.OdcModule" diff --git a/conf/routes b/conf/routes index 6f2e73b..7a2e912 100644 --- a/conf/routes +++ b/conf/routes @@ -33,6 +33,9 @@ GET /stats/libraries/all controllers.Statistics.allLibrarie GET /stats/libraries/files controllers.Statistics.allFiles(selector: Option[String]) GET /stats/libraries/gavs controllers.Statistics.allGavs(selector: Option[String]) +GET /advisor controllers.LibraryAdvisor.index(dependency: Option[String] ?= None) +POST /advisor/scan controllers.LibraryAdvisor.scan() + GET /notifications controllers.Notifications.listProjects(filter: Option[String]) POST /notifications/watch controllers.Notifications.watch(project: String, filter: Option[String]) POST /notifications/unwatch controllers.Notifications.unwatch(project: String, filter: Option[String]) diff --git a/test/factories/ReportsFactory.scala b/test/factories/ReportsFactory.scala index cfc526d..ea14a32 100644 --- a/test/factories/ReportsFactory.scala +++ b/test/factories/ReportsFactory.scala @@ -38,7 +38,7 @@ object ReportsFactory{ license = "something", vulnerabilities = Seq(), suppressedVulnerabilities = Seq(), - relatedDependencies = SerializableXml("") + relatedDependencies = Seq() ) }