From 72395a9ccb0d2065834f363b12b4b41e022732b1 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Tue, 15 Nov 2016 14:33:55 +0100 Subject: [PATCH 01/38] Bump version to 2.9.1-SNAPSHOT --- project/BuildSettings.scala | 2 +- ui/bower.json | 2 +- ui/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 50f79d45c6..0df7f3744b 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -7,7 +7,7 @@ object BasicSettings extends AutoPlugin { override def projectSettings = Seq( organization := "org.cert-bdf", licenses += "AGPL-V3" -> url("https://www.gnu.org/licenses/agpl-3.0.html"), - version := "2.9.0", + version := "2.9.1-SNAPSHOT", resolvers += Resolver.bintrayRepo("cert-bdf", "elastic4play"), scalaVersion := Dependencies.scalaVersion, scalacOptions ++= Seq( diff --git a/ui/bower.json b/ui/bower.json index e1b589cb22..7091646658 100644 --- a/ui/bower.json +++ b/ui/bower.json @@ -1,6 +1,6 @@ { "name": "thehive", - "version": "2.9.0", + "version": "2.9.1-SNAPSHOT", "license": "AGPL-3.0", "dependencies": { "angular": "^1.5.8", diff --git a/ui/package.json b/ui/package.json index ff8ba547a9..3d691964e6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "thehive", - "version": "2.9.0", + "version": "2.9.1-SNAPSHOT", "license": "AGPL-3.0", "repository": { "type": "git", From db0c3f2a418896114ec5d30ed3ed1c195b897515 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Tue, 15 Nov 2016 14:35:58 +0100 Subject: [PATCH 02/38] =?UTF-8?q?#13=20Refactor=20the=20=E2=80=9Capp.case.?= =?UTF-8?q?tasks-item=E2=80=9D=20and=20fetch=20the=20task=20object=20throu?= =?UTF-8?q?gh=20the=20route=20resolve,=20to=20make=20sure=20the=20template?= =?UTF-8?q?=20is=20loaded=20with=20the=20complete=20task=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/app/scripts/app.js | 20 ++++++- .../controllers/case/CaseTasksItemCtrl.js | 56 ++++++++----------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/ui/app/scripts/app.js b/ui/app/scripts/app.js index c986312755..44d75a8a35 100644 --- a/ui/app/scripts/app.js +++ b/ui/app/scripts/app.js @@ -169,7 +169,23 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ui.bootstrap', 'ui.router .state('app.case.tasks-item', { url: '/tasks/{itemId}', templateUrl: 'views/partials/case/case.tasks.item.html', - controller: 'CaseTasksItemCtrl' + controller: 'CaseTasksItemCtrl', + resolve: { + task: function($q, $stateParams, CaseTaskSrv, AlertSrv) { + var deferred = $q.defer(); + + CaseTaskSrv.get({ + 'taskId': $stateParams.itemId + }, function(data) { + deferred.resolve(data); + }, function(response) { + deferred.reject(response); + AlertSrv.error('taskDetails', response.data, response.status); + }); + + return deferred.promise; + } + } }) .state('app.case.observables', { url: '/observables', @@ -244,7 +260,7 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ui.bootstrap', 'ui.router }) .config(['markedProvider', 'hljsServiceProvider', function(markedProvider, hljsServiceProvider) { 'use strict'; - + // marked config markedProvider.setOptions({ gfm: true, diff --git a/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js b/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js index 162e96c4bb..b142f73233 100644 --- a/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js +++ b/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js @@ -1,7 +1,7 @@ (function() { 'use strict'; angular.module('theHiveControllers').controller('CaseTasksItemCtrl', - function($scope, $state, $stateParams, CaseTabsSrv, CaseTaskSrv, PSearchSrv, TaskLogSrv, AlertSrv) { + function($scope, $state, $stateParams, CaseTabsSrv, CaseTaskSrv, PSearchSrv, TaskLogSrv, AlertSrv, task) { var caseId = $stateParams.caseId, taskId = $stateParams.itemId; @@ -16,39 +16,8 @@ attachmentCollapsed: true, logMissing: '' }; - $scope.task = {}; - - CaseTaskSrv.get({ - 'taskId': taskId - }, function(data) { - - var task = data, - taskName = 'task-' + task.id; - - // Add tabs - CaseTabsSrv.addTab(taskName, { - name: taskName, - label: task.title, - closable: true, - state: 'app.case.tasks-item', - params: { - itemId: task.id - } - }); - - // Select tab - CaseTabsSrv.activateTab(taskName); - - // Prepare the scope data - $scope.initScope(data); - - }, function(response) { - AlertSrv.error('taskDetails', response.data, response.status); - CaseTabsSrv.activateTab('tasks'); - }); - $scope.initScope = function(task) { - $scope.task = task; + $scope.initScope = function() { $scope.logs = PSearchSrv(caseId, 'case_task_log', { 'filter': { @@ -128,6 +97,27 @@ return true; }; + + // Initialize controller + $scope.task = task; + var taskName = 'task-' + task.id; + + // Add tabs + CaseTabsSrv.addTab(taskName, { + name: taskName, + label: task.title, + closable: true, + state: 'app.case.tasks-item', + params: { + itemId: task.id + } + }); + + // Select tab + CaseTabsSrv.activateTab(taskName); + + // Prepare the scope data + $scope.initScope(task); } ); }()); From 53ee39b2f55e67b7ff49eddb3f3f0fe769363e9b Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Wed, 16 Nov 2016 11:41:11 +0100 Subject: [PATCH 03/38] #14 add initial support to case merging --- project/Dependencies.scala | 2 +- thehive-backend/app/controllers/Case.scala | 11 +- .../app/services/ArtifactSrv.scala | 2 +- .../app/services/CaseMergeSrv.scala | 221 ++++++++++++++++++ thehive-backend/app/services/CaseSrv.scala | 3 +- thehive-backend/conf/routes | 1 + ui/app/index.html | 1 + .../scripts/controllers/case/CaseMainCtrl.js | 14 ++ .../controllers/case/CaseMergeModalCtrl.js | 53 +++++ ui/app/scripts/services/CaseSrv.js | 8 + ui/app/styles/case.css | 7 + ui/app/views/partials/case/case.merge.html | 44 ++++ .../views/partials/case/case.panelinfo.html | 21 +- 13 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 thehive-backend/app/services/CaseMergeSrv.scala create mode 100644 ui/app/scripts/controllers/case/CaseMergeModalCtrl.js create mode 100644 ui/app/views/partials/case/case.merge.html diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 17e2796bac..aeffdb083e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -29,7 +29,7 @@ object Dependencies { val reflections = "org.reflections" % "reflections" % "0.9.10" val zip4j = "net.lingala.zip4j" % "zip4j" % "1.3.2" val akkaTest = "com.typesafe.akka" %% "akka-stream-testkit" % "2.4.4" - val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.1.0" + val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.1.1-AIV-SNAPSHOT" object Elastic4s { private val version = "2.3.0" diff --git a/thehive-backend/app/controllers/Case.scala b/thehive-backend/app/controllers/Case.scala index ef0c4643e6..16d993d1f2 100644 --- a/thehive-backend/app/controllers/Case.scala +++ b/thehive-backend/app/controllers/Case.scala @@ -24,10 +24,12 @@ import org.elastic4play.services.JsonFormat.{ aggReads, queryReads } import models.{ Case, CaseStatus } import services.{ CaseSrv, TaskSrv } +import services.CaseMergeSrv @Singleton class CaseCtrl @Inject() ( caseSrv: CaseSrv, + caseMergeSrv: CaseMergeSrv, taskSrv: TaskSrv, auxSrv: AuxSrv, authenticated: Authenticated, @@ -64,7 +66,7 @@ class CaseCtrl @Inject() ( @Timed def bulkUpdate() = authenticated(Role.write).async(fieldsBodyParser) { implicit request => val isCaseClosing = request.body.getString("status").filter(_ == CaseStatus.Resolved.toString).isDefined - + request.body.getStrings("ids").fold(Future.successful(Ok(JsArray()))) { ids => if (isCaseClosing) taskSrv.closeTasksOfCase(ids: _*) // FIXME log warning if closedTasks contains errors caseSrv.bulkUpdate(ids, request.body.unset("ids")).map(multiResult => renderer.toMultiOutput(OK, multiResult)) @@ -113,4 +115,11 @@ class CaseCtrl @Inject() ( renderer.toOutput(OK, casesList) } } + + @Timed + def merge(caseId1: String, caseId2: String) = authenticated(Role.read).async { implicit request => + caseMergeSrv.merge(caseId1, caseId2).map { caze => + renderer.toOutput(OK, caze) + } + } } \ No newline at end of file diff --git a/thehive-backend/app/services/ArtifactSrv.scala b/thehive-backend/app/services/ArtifactSrv.scala index cab31e429e..c3d5850df9 100644 --- a/thehive-backend/app/services/ArtifactSrv.scala +++ b/thehive-backend/app/services/ArtifactSrv.scala @@ -102,7 +102,7 @@ class ArtifactSrv @Inject() ( def findSimilar(artifact: Artifact, range: Option[String], sortBy: Seq[String]) = find(similarArtifactFilter(artifact), range, sortBy) - private def similarArtifactFilter(artifact: Artifact): QueryDef = { + private[services] def similarArtifactFilter(artifact: Artifact): QueryDef = { import org.elastic4play.services.QueryDSL._ val dataType = artifact.dataType() artifact.data() match { diff --git a/thehive-backend/app/services/CaseMergeSrv.scala b/thehive-backend/app/services/CaseMergeSrv.scala new file mode 100644 index 0000000000..13d6e30610 --- /dev/null +++ b/thehive-backend/app/services/CaseMergeSrv.scala @@ -0,0 +1,221 @@ +package services + +import java.util.Date + +import javax.inject.{ Inject, Singleton } + +import scala.concurrent.{ ExecutionContext, Future } +import scala.math.BigDecimal.long2bigDecimal + +import akka.Done +import akka.stream.Materializer +import akka.stream.scaladsl.Sink + +import play.api.libs.json.{ JsArray, JsBoolean, JsNull, JsNumber, JsObject, JsString, JsValue } +import play.api.libs.json.JsValue.jsValueToJsLookup +import play.api.libs.json.Json + +import org.elastic4play.controllers.{ AttachmentInputValue, Fields } +import org.elastic4play.models.BaseEntity +import org.elastic4play.services.AuthContext +import org.elastic4play.services.JsonFormat.log +import org.elastic4play.services.QueryDSL + +import models.{ Artifact, ArtifactStatus, Case, CaseImpactStatus, CaseResolutionStatus, CaseStatus, JobStatus, Task } + +@Singleton +class CaseMergeSrv @Inject() (caseSrv: CaseSrv, + taskSrv: TaskSrv, + logSrv: LogSrv, + artifactSrv: ArtifactSrv, + jobSrv: JobSrv, + implicit val ec: ExecutionContext, + implicit val mat: Materializer) { + + import QueryDSL._ + private[services] def concat[E](entities: Seq[E], sep: String, getId: E => Long, getStr: E => String) = { + JsString(entities.map(e => s"#${getId(e)}:${getStr(e)}").mkString(sep)) + } + + private[services] def firstDate(dates: Seq[Date]) = Json.toJson(dates.min) + + private[services] def mergeResolutionStatus(cases: Seq[Case]) = { + val resolutionStatus = cases + .map(_.resolutionStatus()) + .reduce[Option[CaseResolutionStatus.Type]] { + case (None, s) => s + case (s, None) => s + case (Some(CaseResolutionStatus.Other), s) => s + case (s, Some(CaseResolutionStatus.Other)) => s + case (Some(CaseResolutionStatus.FalsePositive), s) => s + case (s, Some(CaseResolutionStatus.FalsePositive)) => s + case (Some(CaseResolutionStatus.Indeterminate), s) => s + case (s, Some(CaseResolutionStatus.Indeterminate)) => s + case (s, _) => s //TruePositive + } + resolutionStatus.map(s => JsString(s.toString)) + } + + private[services] def mergeImpactStatus(cases: Seq[Case]) = { + val impactStatus = cases + .map(_.impactStatus()) + .reduce[Option[CaseImpactStatus.Type]] { + case (None, s) => s + case (s, None) => s + case (Some(CaseImpactStatus.NotApplicable), s) => s + case (s, Some(CaseImpactStatus.NotApplicable)) => s + case (Some(CaseImpactStatus.NoImpact), s) => s + case (s, Some(CaseImpactStatus.NoImpact)) => s + case (s, _) => s // WithImpact + } + impactStatus.map(s => JsString(s.toString)) + } + + private[services] def mergeSummary(cases: Seq[Case]) = { + val summary = cases + .flatMap(c => c.summary().map(_ -> c.caseId())) + .map { + case (summary, caseId) => s"#$caseId:$summary" + } + if (summary.isEmpty) + None + else + Some(JsString(summary.mkString(" / "))) + } + + private[services] def mergeMetrics(cases: Seq[Case]): JsObject = { + val metrics = for { + caze <- cases + metrics <- caze.metrics() + metricsObject <- metrics.asOpt[JsObject] + } yield metricsObject + + val mergedMetrics: Seq[(String, JsValue)] = metrics.flatMap(_.keys).distinct.map { key => + val metricValues = metrics.flatMap(m => (m \ key).asOpt[BigDecimal]) + if (metricValues.size != 1) + key -> JsNull + else + key -> JsNumber(metricValues.head) + } + + JsObject(mergedMetrics) + } + + private[services] def baseFields(entity: BaseEntity): Fields = Fields(entity.attributes - "_id" - "_routing" - "_parent" - "_type" - "createdBy" - "createdAt" - "updatedBy" - "updatedAt" - "user") + + private[services] def mergeLogs(oldTask: Task, newTask: Task)(implicit authContext: AuthContext): Future[Done] = { + logSrv.find("_parent" ~= oldTask.id, Some("all"), Nil)._1 + .mapAsyncUnordered(5) { log => + logSrv.create(newTask, baseFields(log)) + } + .runWith(Sink.ignore) + } + + private[services] def mergeTasksAndLogs(newCase: Case, cases: Seq[Case])(implicit authContext: AuthContext): Future[Done] = { + taskSrv.find(or(cases.map("_parent" ~= _.id)), Some("all"), Nil)._1 + .mapAsyncUnordered(5) { task => + taskSrv.create(newCase, baseFields(task)).map(task -> _) + } + .flatMapConcat { + case (oldTask, newTask) => + logSrv.find("_parent" ~= oldTask.id, Some("all"), Nil)._1 + .map(_ -> newTask) + } + .mapAsyncUnordered(5) { + case (log, task) => logSrv.create(task, baseFields(log)) + } + .runWith(Sink.ignore) + } + + private[services] def mergeArtifactStatus(artifacts: Seq[Artifact]) = { + val status = artifacts + .map(_.status()) + .reduce[ArtifactStatus.Type] { + case (ArtifactStatus.Deleted, s) => s + case (s, _) => s + } + .toString + JsString(status) + } + + private[services] def mergeJobs(newArtifact: Artifact, artifacts: Seq[Artifact])(implicit authContext: AuthContext): Future[Done] = { + jobSrv.find(and(or(artifacts.map("_parent" ~= _.id)), "status" ~= JobStatus.Success), Some("all"), Nil)._1 + .mapAsyncUnordered(5) { job => + jobSrv.create(newArtifact, baseFields(job)) + } + .runWith(Sink.ignore) + } + + private[services] def mergeArtifactsAndJobs(newCase: Case, cases: Seq[Case])(implicit authContext: AuthContext): Future[Done] = { + val caseMap = cases.map(c => c.id -> c).toMap + val caseFilter = or(cases.map("_parent" ~= _.id)) + // Find artifacts hold by cases + artifactSrv.find(caseFilter, Some("all"), Nil)._1 + .map { artifact => + // For each artifact find similar artifacts + val dataFilter = artifact.data().map("data" ~= _) orElse artifact.attachment().map("attachment.id" ~= _.id) + val filter = and(caseFilter, + "status" ~= "Ok", + "dataType" ~= artifact.dataType(), + dataFilter.get) + artifactSrv.find(filter, Some("all"), Nil)._1 + .runWith(Sink.seq) + .flatMap { sameArtifacts => + // Same artifacts are merged + val firstArtifact = sameArtifacts.head + val fields = firstArtifact.attachment().fold(Fields.empty) { a => + Fields.empty.set("attachment", AttachmentInputValue(a.name, a.hashes, a.size, a.contentType, a.id)) + } + .set("data", firstArtifact.data().map(JsString)) + .set("dataType", firstArtifact.dataType()) + .set("message", concat[Artifact](sameArtifacts, "\n \n", a => caseMap(a.parentId.get).caseId(), _.message())) + .set("startDate", firstDate(sameArtifacts.map(_.startDate()))) + .set("tlp", JsNumber(sameArtifacts.map(_.tlp()).min)) + .set("tags", JsArray(sameArtifacts.flatMap(_.tags()).map(JsString))) + .set("ioc", JsBoolean(sameArtifacts.map(_.ioc()).reduce(_ || _))) + .set("status", mergeArtifactStatus(sameArtifacts)) + // Merged artifact is created under new case + artifactSrv + .create(newCase, fields) + // Then jobs are imported + .flatMap { newArtifact => + mergeJobs(newArtifact, sameArtifacts) + } + // Errors are logged and ignored (probably document already exists) + .recover { + case error => + log.warn("Artifact creation fail", error) + Done + } + } + } + .runWith(Sink.ignore) + } + + private[services] def mergeCases(cases: Seq[Case])(implicit authContext: AuthContext): Future[Case] = { + val fields = Fields.empty + .set("title", concat[Case](cases, " / ", _.caseId(), _.title())) + .set("description", concat[Case](cases, "\n \n", _.caseId(), _.description())) + .set("severity", JsNumber(cases.map(_.severity()).max)) + .set("startDate", firstDate(cases.map(_.startDate()))) + .set("tags", JsArray(cases.flatMap(_.tags()).distinct.map(JsString))) + .set("flag", JsBoolean(cases.map(_.flag()).reduce(_ || _))) + .set("tlp", JsNumber(cases.map(_.tlp()).max)) + .set("status", JsString(CaseStatus.Open.toString)) + .set("metrics", mergeMetrics(cases)) + .set("isIncident", JsBoolean(cases.map(_.isIncident()).reduce(_ || _))) + .set("resolutionStatus", mergeResolutionStatus(cases)) + .set("impactStatus", mergeImpactStatus(cases)) + .set("summary", mergeSummary(cases)) + caseSrv.create(fields) + } + + def merge(caseIds: String*)(implicit authContext: AuthContext): Future[Case] = { + for { + cases <- Future.sequence(caseIds.map(caseSrv.get)) + newCase <- mergeCases(cases) + _ <- mergeTasksAndLogs(newCase, cases) + _ <- mergeArtifactsAndJobs(newCase, cases) + } yield newCase + } +} \ No newline at end of file diff --git a/thehive-backend/app/services/CaseSrv.scala b/thehive-backend/app/services/CaseSrv.scala index 4ffc531278..49e22c5227 100644 --- a/thehive-backend/app/services/CaseSrv.scala +++ b/thehive-backend/app/services/CaseSrv.scala @@ -25,7 +25,6 @@ class CaseSrv @Inject() ( taskModel: TaskModel, createSrv: CreateSrv, artifactSrv: ArtifactSrv, - taskSrv: TaskSrv, getSrv: GetSrv, updateSrv: UpdateSrv, deleteSrv: DeleteSrv, @@ -45,7 +44,7 @@ class CaseSrv @Inject() ( } } - def get(id: String)(implicit authContext: AuthContext): Future[Case] = + def get(id: String): Future[Case] = getSrv[CaseModel, Case](caseModel, id) def update(id: String, fields: Fields)(implicit authContext: AuthContext): Future[Case] = diff --git a/thehive-backend/conf/routes b/thehive-backend/conf/routes index 89582a054b..57a4dd57cc 100644 --- a/thehive-backend/conf/routes +++ b/thehive-backend/conf/routes @@ -18,6 +18,7 @@ GET /api/case/:caseId controllers.CaseCtrl.get(cas PATCH /api/case/:caseId controllers.CaseCtrl.update(caseId) DELETE /api/case/:caseId controllers.CaseCtrl.delete(caseId) GET /api/case/:caseId/links controllers.CaseCtrl.linkedCases(caseId) +POST /api/case/:caseId1/_merge/:caseId2 controllers.CaseCtrl.merge(caseId1, caseId2) POST /api/case/template/_search controllers.CaseTemplateCtrl.find() POST /api/case/template controllers.CaseTemplateCtrl.create() diff --git a/ui/app/index.html b/ui/app/index.html index d600d555a1..417199527d 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -114,6 +114,7 @@ + diff --git a/ui/app/scripts/controllers/case/CaseMainCtrl.js b/ui/app/scripts/controllers/case/CaseMainCtrl.js index e7a9687afd..7d860a0da4 100644 --- a/ui/app/scripts/controllers/case/CaseMainCtrl.js +++ b/ui/app/scripts/controllers/case/CaseMainCtrl.js @@ -170,6 +170,20 @@ }); }; + $scope.mergeCase = function() { + $modal.open({ + templateUrl: 'views/partials/case/case.merge.html', + controller: 'CaseMergeModalCtrl', + controllerAs: 'dialog', + size: 'lg', + resolve: { + caze: function() { + return $scope.caze; + } + } + }); + }; + /** * A workaround filter to make sure the ngRepeat doesn't order the * object keys diff --git a/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js new file mode 100644 index 0000000000..85bf6ef0d3 --- /dev/null +++ b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js @@ -0,0 +1,53 @@ +(function() { + 'use strict'; + + angular.module('theHiveControllers') + .controller('CaseMergeModalCtrl', CaseMergeModalCtrl); + + function CaseMergeModalCtrl($state, $modalInstance, SearchSrv, CaseSrv, UserInfoSrv, AlertSrv, caze) { + var me = this; + + this.caze = caze; + this.search = { + caseId: null, + cases: [] + }; + this.getUserInfo = UserInfoSrv; + + this.getCaseByNumber = function() { + if (this.search.caseId && this.search.caseId !== this.caze.caseId) { + SearchSrv(function(data /*, total*/ ) { + console.log(data); + me.search.cases = data; + }, { + _string: 'caseId:' + me.search.caseId + }, 'case', 'all'); + } else { + this.search.cases = []; + } + }; + + this.merge = function() { + // TODO pass params as path params not query params + CaseSrv.merge({}, { + caseId: me.caze.id, + mergedCaseId: me.search.cases[0].id + }, function(merged) { + + $state.go('app.case.details', { + caseId: merged.id + }); + + $modalInstance.dismiss(); + + AlertSrv.log('The cases have been successfully merged into a new case #' + merged.caseId, 'success'); + }, function(response){ + AlertSrv.error('CaseMergeModalCtrl', response.data, response.status); + }); + }; + + this.cancel = function() { + $modalInstance.dismiss(); + }; + } +})(); diff --git a/ui/app/scripts/services/CaseSrv.js b/ui/app/scripts/services/CaseSrv.js index 1b8f78afa4..6b7288a6a8 100644 --- a/ui/app/scripts/services/CaseSrv.js +++ b/ui/app/scripts/services/CaseSrv.js @@ -10,6 +10,14 @@ method: 'GET', url: '/api/case/:caseId/links', isArray: true + }, + merge: { + method: 'POST', + url: '/api/case/:caseId/_merge/:mergedCaseId', + params: { + caseId: '@caseId', + mergedCaseId: '@mergedCaseId', + } } }); }); diff --git a/ui/app/styles/case.css b/ui/app/styles/case.css index 3ce6ed970d..02cacd4b5f 100644 --- a/ui/app/styles/case.css +++ b/ui/app/styles/case.css @@ -49,3 +49,10 @@ span.link-id { padding: 0 15px; } + + +.merge-dialog .merge-case { + background-color: #f5f5f5; + padding: 10px; + overflow: hidden; +} diff --git a/ui/app/views/partials/case/case.merge.html b/ui/app/views/partials/case/case.merge.html new file mode 100644 index 0000000000..1b1bfa7397 --- /dev/null +++ b/ui/app/views/partials/case/case.merge.html @@ -0,0 +1,44 @@ + + + diff --git a/ui/app/views/partials/case/case.panelinfo.html b/ui/app/views/partials/case/case.panelinfo.html index fe011081ef..c9b013d7b3 100644 --- a/ui/app/views/partials/case/case.panelinfo.html +++ b/ui/app/views/partials/case/case.panelinfo.html @@ -9,22 +9,27 @@

- {{caze.title}} - + + + + + + - - + + - + - + - + - + - +

From 05be4c43ac11c409036d77e69be6957cf4135163 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Wed, 16 Nov 2016 14:26:49 +0100 Subject: [PATCH 04/38] =?UTF-8?q?#14=20Add=20tooltip=20to=20the=20?= =?UTF-8?q?=E2=80=9CFlag/Unflag=E2=80=9D=20case=20button,=20and=20fix=20an?= =?UTF-8?q?=20issue=20in=20HTML=20(missing=20closing=20tag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/app/views/partials/case/case.panelinfo.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/app/views/partials/case/case.panelinfo.html b/ui/app/views/partials/case/case.panelinfo.html index c9b013d7b3..44741ee76e 100644 --- a/ui/app/views/partials/case/case.panelinfo.html +++ b/ui/app/views/partials/case/case.panelinfo.html @@ -10,26 +10,26 @@

{{caze.title}} - + - + - + - + -

From e47513cf021be5e4884689eb23576d696027b83f Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Wed, 16 Nov 2016 15:21:59 +0100 Subject: [PATCH 05/38] #15 Fix the Action button's label in the observables list when returning back from export page --- .../views/partials/observables/list/artifacts-list-export.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/views/partials/observables/list/artifacts-list-export.html b/ui/app/views/partials/observables/list/artifacts-list-export.html index ee3e951375..1737bd172a 100644 --- a/ui/app/views/partials/observables/list/artifacts-list-export.html +++ b/ui/app/views/partials/observables/list/artifacts-list-export.html @@ -4,7 +4,7 @@
- + Back From 87cda0326bae5dfeeecf7537cab2b240f9824e7c Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Thu, 17 Nov 2016 14:42:46 +0100 Subject: [PATCH 06/38] #14 improve the case merge dialog to support autocomplete, search by title and by case number --- .../controllers/case/CaseMergeModalCtrl.js | 69 ++++++++++++++----- ui/app/styles/case.css | 5 ++ ui/app/views/partials/case/case.merge.html | 28 +++++--- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js index 85bf6ef0d3..e4336fa71a 100644 --- a/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js +++ b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js @@ -1,38 +1,69 @@ -(function() { +(function () { 'use strict'; angular.module('theHiveControllers') .controller('CaseMergeModalCtrl', CaseMergeModalCtrl); - function CaseMergeModalCtrl($state, $modalInstance, SearchSrv, CaseSrv, UserInfoSrv, AlertSrv, caze) { + function CaseMergeModalCtrl($state, $modalInstance, $q, SearchSrv, CaseSrv, UserInfoSrv, AlertSrv, caze, $http) { var me = this; this.caze = caze; + this.pendingAsync = false; this.search = { - caseId: null, + type: 'title', + placeholder: 'Search by case title', + minInputLength: 1, + input: null, cases: [] }; this.getUserInfo = UserInfoSrv; - this.getCaseByNumber = function() { - if (this.search.caseId && this.search.caseId !== this.caze.caseId) { - SearchSrv(function(data /*, total*/ ) { - console.log(data); - me.search.cases = data; - }, { - _string: 'caseId:' + me.search.caseId - }, 'case', 'all'); - } else { - this.search.cases = []; + this.getCaseByTitle = function(type, input) { + var defer = $q.defer(); + + SearchSrv(function (data /*, total*/ ) { + defer.resolve(data); + }, { + _string: (type === 'title') ? ('title:"' + input + '"') : ('caseId:' + input) + }, 'case', 'all'); + + return defer.promise; + } + + this.format = function(caze) { + if(caze) { + return '#' + caze.caseId + ' - ' + caze.title; } - }; + return null; + } + + this.clearSearch = function() { + this.search.input = null; + this.search.cases = []; + } + + this.onTypeChange = function(type) { + this.clearSearch(); - this.merge = function() { + this.search.placeholder = 'Search by case ' + type; + + if(type === 'title') { + this.search.minInputLength = 3; + } else if(type === 'number') { + this.search.minInputLength = 1; + } + } + + this.onSelect = function(item, model, label) { + this.search.cases = [item]; + } + + this.merge = function () { // TODO pass params as path params not query params CaseSrv.merge({}, { caseId: me.caze.id, mergedCaseId: me.search.cases[0].id - }, function(merged) { + }, function (merged) { $state.go('app.case.details', { caseId: merged.id @@ -41,13 +72,13 @@ $modalInstance.dismiss(); AlertSrv.log('The cases have been successfully merged into a new case #' + merged.caseId, 'success'); - }, function(response){ + }, function (response) { AlertSrv.error('CaseMergeModalCtrl', response.data, response.status); }); }; - this.cancel = function() { + this.cancel = function () { $modalInstance.dismiss(); - }; + }; } })(); diff --git a/ui/app/styles/case.css b/ui/app/styles/case.css index 02cacd4b5f..c4dda5dccf 100644 --- a/ui/app/styles/case.css +++ b/ui/app/styles/case.css @@ -56,3 +56,8 @@ span.link-id { padding: 10px; overflow: hidden; } + +.merge-dialog .search-field ul.dropdown-menu { + width: 100%; + left: 0 !important; +} \ No newline at end of file diff --git a/ui/app/views/partials/case/case.merge.html b/ui/app/views/partials/case/case.merge.html index 1b1bfa7397..7bcea20037 100644 --- a/ui/app/views/partials/case/case.merge.html +++ b/ui/app/views/partials/case/case.merge.html @@ -2,12 +2,25 @@
From 4e3ff6a299b1d2f97bd98306be7433dc1cc09d17 Mon Sep 17 00:00:00 2001 From: To-om Date: Thu, 17 Nov 2016 16:36:00 +0100 Subject: [PATCH 07/38] #14 Fix case merging in backend --- thehive-backend/app/models/Case.scala | 6 +- thehive-backend/app/models/Job.scala | 2 +- .../app/services/CaseMergeSrv.scala | 208 +++++++++++------- 3 files changed, 135 insertions(+), 81 deletions(-) diff --git a/thehive-backend/app/models/Case.scala b/thehive-backend/app/models/Case.scala index 4dea21f1bc..91afb67620 100644 --- a/thehive-backend/app/models/Case.scala +++ b/thehive-backend/app/models/Case.scala @@ -21,12 +21,12 @@ import services.AuditedModel object CaseStatus extends Enumeration with HiveEnumeration { type Type = Value - val Ephemeral, Open, FalsePositive, TruePositive, Resolved, Deleted = Value + val Open, Resolved, Deleted = Value } object CaseResolutionStatus extends Enumeration with HiveEnumeration { type Type = Value - val Indeterminate, FalsePositive, TruePositive, Other = Value + val Indeterminate, FalsePositive, TruePositive, Other, Duplicated = Value } object CaseImpactStatus extends Enumeration with HiveEnumeration { @@ -50,6 +50,8 @@ trait CaseAttributes { _: AttributeDef => val resolutionStatus = optionalAttribute("resolutionStatus", F.enumFmt(CaseResolutionStatus), "Resolution status of the case") val impactStatus = optionalAttribute("impactStatus", F.enumFmt(CaseImpactStatus), "Impact status of the case") val summary = optionalAttribute("summary", F.textFmt, "Summary of the case, to be provided when closing a case") + val mergeInto = optionalAttribute("mergeInto",F.stringFmt, "Id of the case created by the merge") + val mergeFrom = multiAttribute("mergeFrom",F.stringFmt, "Id of the cases merged") } @Singleton diff --git a/thehive-backend/app/models/Job.scala b/thehive-backend/app/models/Job.scala index f90c715948..b5f5b895f5 100644 --- a/thehive-backend/app/models/Job.scala +++ b/thehive-backend/app/models/Job.scala @@ -23,7 +23,7 @@ trait JobAttributes { _: AttributeDef => val analyzerId = attribute("analyzerId", F.stringFmt, "Analyzer", O.readonly) val status = attribute("status", F.enumFmt(JobStatus), "Status of the job", JobStatus.InProgress) val artifactId = attribute("artifactId", F.stringFmt, "Original artifact on which this job was executed", O.readonly) - val startDate = attribute("startDate", F.dateFmt, "Timestamp of the job start", O.model) + val startDate = attribute("startDate", F.dateFmt, "Timestamp of the job start") // , O.model) val endDate = optionalAttribute("endDate", F.dateFmt, "Timestamp of the job completion (or fail)") val report = optionalAttribute("report", F.textFmt, "Analysis result", O.unaudited) diff --git a/thehive-backend/app/services/CaseMergeSrv.scala b/thehive-backend/app/services/CaseMergeSrv.scala index 13d6e30610..e0470416c9 100644 --- a/thehive-backend/app/services/CaseMergeSrv.scala +++ b/thehive-backend/app/services/CaseMergeSrv.scala @@ -22,6 +22,11 @@ import org.elastic4play.services.JsonFormat.log import org.elastic4play.services.QueryDSL import models.{ Artifact, ArtifactStatus, Case, CaseImpactStatus, CaseResolutionStatus, CaseStatus, JobStatus, Task } +import play.api.Logger +import scala.util.Success +import scala.util.Failure +import models.TaskStatus +import models.LogStatus @Singleton class CaseMergeSrv @Inject() (caseSrv: CaseSrv, @@ -32,9 +37,20 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, implicit val ec: ExecutionContext, implicit val mat: Materializer) { + lazy val logger = Logger(getClass) + import QueryDSL._ - private[services] def concat[E](entities: Seq[E], sep: String, getId: E => Long, getStr: E => String) = { - JsString(entities.map(e => s"#${getId(e)}:${getStr(e)}").mkString(sep)) + private[services] def concat[E](entities: Seq[E], sep: String, getId: E ⇒ Long, getStr: E ⇒ String) = { + JsString(entities.map(e ⇒ s"#${getId(e)}:${getStr(e)}").mkString(sep)) + } + + private[services] def concatCaseDescription(cases: Seq[Case]) = { + val str = cases + .map { caze ⇒ + s"#### ${caze.title()} ([#${caze.caseId()}](#/case/${caze.id}/details))\n\n${caze.description()}" + } + .mkString("\n \n") + JsString(str) } private[services] def firstDate(dates: Seq[Date]) = Json.toJson(dates.min) @@ -43,39 +59,39 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, val resolutionStatus = cases .map(_.resolutionStatus()) .reduce[Option[CaseResolutionStatus.Type]] { - case (None, s) => s - case (s, None) => s - case (Some(CaseResolutionStatus.Other), s) => s - case (s, Some(CaseResolutionStatus.Other)) => s - case (Some(CaseResolutionStatus.FalsePositive), s) => s - case (s, Some(CaseResolutionStatus.FalsePositive)) => s - case (Some(CaseResolutionStatus.Indeterminate), s) => s - case (s, Some(CaseResolutionStatus.Indeterminate)) => s - case (s, _) => s //TruePositive + case (None, s) ⇒ s + case (s, None) ⇒ s + case (Some(CaseResolutionStatus.Other), s) ⇒ s + case (s, Some(CaseResolutionStatus.Other)) ⇒ s + case (Some(CaseResolutionStatus.FalsePositive), s) ⇒ s + case (s, Some(CaseResolutionStatus.FalsePositive)) ⇒ s + case (Some(CaseResolutionStatus.Indeterminate), s) ⇒ s + case (s, Some(CaseResolutionStatus.Indeterminate)) ⇒ s + case (s, _) ⇒ s //TruePositive } - resolutionStatus.map(s => JsString(s.toString)) + resolutionStatus.map(s ⇒ JsString(s.toString)) } private[services] def mergeImpactStatus(cases: Seq[Case]) = { val impactStatus = cases .map(_.impactStatus()) .reduce[Option[CaseImpactStatus.Type]] { - case (None, s) => s - case (s, None) => s - case (Some(CaseImpactStatus.NotApplicable), s) => s - case (s, Some(CaseImpactStatus.NotApplicable)) => s - case (Some(CaseImpactStatus.NoImpact), s) => s - case (s, Some(CaseImpactStatus.NoImpact)) => s - case (s, _) => s // WithImpact + case (None, s) ⇒ s + case (s, None) ⇒ s + case (Some(CaseImpactStatus.NotApplicable), s) ⇒ s + case (s, Some(CaseImpactStatus.NotApplicable)) ⇒ s + case (Some(CaseImpactStatus.NoImpact), s) ⇒ s + case (s, Some(CaseImpactStatus.NoImpact)) ⇒ s + case (s, _) ⇒ s // WithImpact } - impactStatus.map(s => JsString(s.toString)) + impactStatus.map(s ⇒ JsString(s.toString)) } private[services] def mergeSummary(cases: Seq[Case]) = { val summary = cases - .flatMap(c => c.summary().map(_ -> c.caseId())) + .flatMap(c ⇒ c.summary().map(_ -> c.caseId())) .map { - case (summary, caseId) => s"#$caseId:$summary" + case (summary, caseId) ⇒ s"#$caseId:$summary" } if (summary.isEmpty) None @@ -85,13 +101,13 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, private[services] def mergeMetrics(cases: Seq[Case]): JsObject = { val metrics = for { - caze <- cases - metrics <- caze.metrics() - metricsObject <- metrics.asOpt[JsObject] + caze ← cases + metrics ← caze.metrics() + metricsObject ← metrics.asOpt[JsObject] } yield metricsObject - val mergedMetrics: Seq[(String, JsValue)] = metrics.flatMap(_.keys).distinct.map { key => - val metricValues = metrics.flatMap(m => (m \ key).asOpt[BigDecimal]) + val mergedMetrics: Seq[(String, JsValue)] = metrics.flatMap(_.keys).distinct.map { key ⇒ + val metricValues = metrics.flatMap(m ⇒ (m \ key).asOpt[BigDecimal]) if (metricValues.size != 1) key -> JsNull else @@ -105,24 +121,30 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, private[services] def mergeLogs(oldTask: Task, newTask: Task)(implicit authContext: AuthContext): Future[Done] = { logSrv.find("_parent" ~= oldTask.id, Some("all"), Nil)._1 - .mapAsyncUnordered(5) { log => + .mapAsyncUnordered(5) { log ⇒ logSrv.create(newTask, baseFields(log)) } .runWith(Sink.ignore) } private[services] def mergeTasksAndLogs(newCase: Case, cases: Seq[Case])(implicit authContext: AuthContext): Future[Done] = { - taskSrv.find(or(cases.map("_parent" ~= _.id)), Some("all"), Nil)._1 - .mapAsyncUnordered(5) { task => - taskSrv.create(newCase, baseFields(task)).map(task -> _) - } + val (tasks, futureTaskCount) = taskSrv.find(and(parent("case", withId(cases.map(_.id): _*)), "status" ~!= TaskStatus.Cancel), Some("all"), Nil) + futureTaskCount.foreach(count ⇒ logger.info(s"Creating $count task(s):")) + tasks + .mapAsyncUnordered(5) { task ⇒ taskSrv.create(newCase, baseFields(task)).map(task -> _) } .flatMapConcat { - case (oldTask, newTask) => - logSrv.find("_parent" ~= oldTask.id, Some("all"), Nil)._1 - .map(_ -> newTask) + case (oldTask, newTask) ⇒ + logger.info(s"\ttask : ${oldTask.id} -> ${newTask.id} : ${newTask.title()}") + val (logs, futureLogCount) = logSrv.find(and(parent("case_task", withId(oldTask.id)), "status" ~!= LogStatus.Deleted), Some("all"), Nil) + futureLogCount.foreach { count ⇒ logger.info(s"Creating $count log(s) in task ${newTask.id}") } + logs.map(_ -> newTask) } .mapAsyncUnordered(5) { - case (log, task) => logSrv.create(task, baseFields(log)) + case (log, task) ⇒ + val fields = log.attachment().fold(baseFields(log)) { a ⇒ + baseFields(log).set("attachment", AttachmentInputValue(a.name, a.hashes, a.size, a.contentType, a.id)) + } + logSrv.create(task, fields) } .runWith(Sink.ignore) } @@ -131,71 +153,86 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, val status = artifacts .map(_.status()) .reduce[ArtifactStatus.Type] { - case (ArtifactStatus.Deleted, s) => s - case (s, _) => s + case (ArtifactStatus.Deleted, s) ⇒ s + case (s, _) ⇒ s } .toString JsString(status) } private[services] def mergeJobs(newArtifact: Artifact, artifacts: Seq[Artifact])(implicit authContext: AuthContext): Future[Done] = { - jobSrv.find(and(or(artifacts.map("_parent" ~= _.id)), "status" ~= JobStatus.Success), Some("all"), Nil)._1 - .mapAsyncUnordered(5) { job => + jobSrv.find(and(parent("case_artifact", withId(artifacts.map(_.id): _*)), "status" ~= JobStatus.Success), Some("all"), Nil)._1 + .mapAsyncUnordered(5) { job ⇒ jobSrv.create(newArtifact, baseFields(job)) } .runWith(Sink.ignore) } private[services] def mergeArtifactsAndJobs(newCase: Case, cases: Seq[Case])(implicit authContext: AuthContext): Future[Done] = { - val caseMap = cases.map(c => c.id -> c).toMap - val caseFilter = or(cases.map("_parent" ~= _.id)) + val caseMap = cases.map(c ⇒ c.id -> c).toMap + val caseFilter = and(parent("case", withId(cases.map(_.id): _*)), "status" ~= "Ok") // Find artifacts hold by cases - artifactSrv.find(caseFilter, Some("all"), Nil)._1 - .map { artifact => + val (artifacts, futureArtifactCount) = artifactSrv.find(caseFilter, Some("all"), Nil) + futureArtifactCount.foreach { count ⇒ log.info(s"Found $count artifact(s) in merging cases") } + artifacts + .mapAsyncUnordered(5) { artifact ⇒ // For each artifact find similar artifacts val dataFilter = artifact.data().map("data" ~= _) orElse artifact.attachment().map("attachment.id" ~= _.id) val filter = and(caseFilter, "status" ~= "Ok", "dataType" ~= artifact.dataType(), dataFilter.get) - artifactSrv.find(filter, Some("all"), Nil)._1 - .runWith(Sink.seq) - .flatMap { sameArtifacts => - // Same artifacts are merged - val firstArtifact = sameArtifacts.head - val fields = firstArtifact.attachment().fold(Fields.empty) { a => - Fields.empty.set("attachment", AttachmentInputValue(a.name, a.hashes, a.size, a.contentType, a.id)) - } - .set("data", firstArtifact.data().map(JsString)) - .set("dataType", firstArtifact.dataType()) - .set("message", concat[Artifact](sameArtifacts, "\n \n", a => caseMap(a.parentId.get).caseId(), _.message())) - .set("startDate", firstDate(sameArtifacts.map(_.startDate()))) - .set("tlp", JsNumber(sameArtifacts.map(_.tlp()).min)) - .set("tags", JsArray(sameArtifacts.flatMap(_.tags()).map(JsString))) - .set("ioc", JsBoolean(sameArtifacts.map(_.ioc()).reduce(_ || _))) - .set("status", mergeArtifactStatus(sameArtifacts)) - // Merged artifact is created under new case - artifactSrv - .create(newCase, fields) - // Then jobs are imported - .flatMap { newArtifact => - mergeJobs(newArtifact, sameArtifacts) - } - // Errors are logged and ignored (probably document already exists) - .recover { - case error => - log.warn("Artifact creation fail", error) - Done - } + + val (artifacts, futureArtifactCount) = artifactSrv.find(filter, Some("all"), Nil) + futureArtifactCount.foreach { count ⇒ + logger.debug(s"${count} identical artifact(s) found (${artifact.dataType()}):${(artifact.data() orElse artifact.attachment().map(_.name)).get}") + } + artifacts.runWith(Sink.seq) + } + .mapAsync(5) { sameArtifacts ⇒ + // Same artifacts are merged + val firstArtifact = sameArtifacts.head + val fields = firstArtifact.attachment().fold(Fields.empty) { a ⇒ + Fields.empty.set("attachment", AttachmentInputValue(a.name, a.hashes, a.size, a.contentType, a.id)) + } + .set("data", firstArtifact.data().map(JsString)) + .set("dataType", firstArtifact.dataType()) + .set("message", concat[Artifact](sameArtifacts, "\n \n", a ⇒ caseMap(a.parentId.get).caseId(), _.message())) + .set("startDate", firstDate(sameArtifacts.map(_.startDate()))) + .set("tlp", JsNumber(sameArtifacts.map(_.tlp()).min)) + .set("tags", JsArray(sameArtifacts.flatMap(_.tags()).map(JsString))) + .set("ioc", JsBoolean(sameArtifacts.map(_.ioc()).reduce(_ || _))) + .set("status", mergeArtifactStatus(sameArtifacts)) + // Merged artifact is created under new case + artifactSrv + .create(newCase, fields) + .map(a ⇒ List(a -> sameArtifacts)) + // Errors are logged and ignored (probably document already exists) + .recover { + case e ⇒ + logger.warn("Artifact creation fail", e) + Nil } } + .mapConcat(identity) + .mapAsyncUnordered(5) { + case (newArtifact, sameArtifacts) ⇒ + // Then jobs are imported + mergeJobs(newArtifact, sameArtifacts) + .recover { + case error ⇒ + logger.error("Log creation fail", error) + Done + } + } .runWith(Sink.ignore) } private[services] def mergeCases(cases: Seq[Case])(implicit authContext: AuthContext): Future[Case] = { + logger.info("Merging cases: " + cases.map(c ⇒ s"#${c.caseId()}:${c.title()}").mkString(" / ")) val fields = Fields.empty .set("title", concat[Case](cases, " / ", _.caseId(), _.title())) - .set("description", concat[Case](cases, "\n \n", _.caseId(), _.description())) + .set("description", concatCaseDescription(cases)) .set("severity", JsNumber(cases.map(_.severity()).max)) .set("startDate", firstDate(cases.map(_.startDate()))) .set("tags", JsArray(cases.flatMap(_.tags()).distinct.map(JsString))) @@ -207,15 +244,30 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, .set("resolutionStatus", mergeResolutionStatus(cases)) .set("impactStatus", mergeImpactStatus(cases)) .set("summary", mergeSummary(cases)) + .set("mergeFrom", JsArray(cases.map(c ⇒ JsString(c.id)))) caseSrv.create(fields) } + def markCaseAsDuplicated(caseIds: Seq[String], mergeCaseId: String)(implicit authContext: AuthContext): Future[Unit] = { + caseSrv.bulkUpdate(caseIds, Fields.empty + .set("mergeInto", mergeCaseId) + .set("status", CaseStatus.Resolved.toString) + .set("resolutionStatus", CaseResolutionStatus.Duplicated.toString)) + .map(_.foreach { + case Success(_) ⇒ Done + case Failure(error) ⇒ + log.error("Case update fail", error) + Done + }) + } + def merge(caseIds: String*)(implicit authContext: AuthContext): Future[Case] = { for { - cases <- Future.sequence(caseIds.map(caseSrv.get)) - newCase <- mergeCases(cases) - _ <- mergeTasksAndLogs(newCase, cases) - _ <- mergeArtifactsAndJobs(newCase, cases) + cases ← Future.sequence(caseIds.map(caseSrv.get)) + newCase ← mergeCases(cases) + _ ← mergeTasksAndLogs(newCase, cases) + _ ← mergeArtifactsAndJobs(newCase, cases) + _ ← markCaseAsDuplicated(caseIds, newCase.id) } yield newCase } } \ No newline at end of file From bf306cf2d550738cb17d9b0bb8965e1e1e6e9955 Mon Sep 17 00:00:00 2001 From: To-om Date: Fri, 18 Nov 2016 13:57:33 +0100 Subject: [PATCH 08/38] #14 exclude initial cases of merge for linked cases and seen observables searchs --- thehive-backend/app/services/ArtifactSrv.scala | 8 +++++--- thehive-backend/app/services/CaseSrv.scala | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/thehive-backend/app/services/ArtifactSrv.scala b/thehive-backend/app/services/ArtifactSrv.scala index c3d5850df9..39ba43234a 100644 --- a/thehive-backend/app/services/ArtifactSrv.scala +++ b/thehive-backend/app/services/ArtifactSrv.scala @@ -13,6 +13,8 @@ import org.elastic4play.services.{ Agg, AuthContext, CreateSrv, DeleteSrv, Field import models.{ Artifact, ArtifactModel, ArtifactStatus, Case, CaseModel, JobModel } import org.elastic4play.utils.{ RichFuture, RichOr } +import models.CaseStatus +import models.CaseResolutionStatus @Singleton class ArtifactSrv @Inject() ( @@ -109,7 +111,7 @@ class ArtifactSrv @Inject() ( // artifact is an hash case Some(d) if dataType == "hash" => and( - not(parent("case", "_id" ~= artifact.parentId.get)), + parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", or( and( @@ -119,7 +121,7 @@ class ArtifactSrv @Inject() ( // artifact contains data but not an hash case Some(d) => and( - not(parent("case", "_id" ~= artifact.parentId.get)), + parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", "data" ~= d, "dataType" ~= dataType) @@ -128,7 +130,7 @@ class ArtifactSrv @Inject() ( val hashes = artifact.attachment().toSeq.flatMap(_.hashes).map(_.toString) val hashFilter = hashes.map { h => "attachment.hashes" ~= h } and( - not(parent("case", "_id" ~= artifact.parentId.get)), + parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", or( hashFilter :+ diff --git a/thehive-backend/app/services/CaseSrv.scala b/thehive-backend/app/services/CaseSrv.scala index 49e22c5227..a1accb73ac 100644 --- a/thehive-backend/app/services/CaseSrv.scala +++ b/thehive-backend/app/services/CaseSrv.scala @@ -17,6 +17,8 @@ import org.elastic4play.controllers.Fields import org.elastic4play.services.{ Agg, AuthContext, CreateSrv, DeleteSrv, FindSrv, GetSrv, QueryDSL, QueryDef, UpdateSrv } import models.{ Artifact, ArtifactModel, Case, CaseModel, Task, TaskModel } +import models.CaseStatus +import models.CaseResolutionStatus @Singleton class CaseSrv @Inject() ( @@ -75,7 +77,7 @@ class CaseSrv @Inject() ( def linkedCases(id: String): Source[(Case, Seq[Artifact]), NotUsed] = { import org.elastic4play.services.QueryDSL._ - findSrv[ArtifactModel, Artifact](artifactModel, parent("case", withId(id)), Some("all"), Nil) + findSrv[ArtifactModel, Artifact](artifactModel, parent("case", and(withId(id), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), Some("all"), Nil) ._1 .flatMapConcat { artifact => artifactSrv.findSimilar(artifact, Some("all"), Nil)._1 } .groupBy(20, _.parentId) From 1dc88a397038931b821b128cd8c21edbd3d6cbc8 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Thu, 17 Nov 2016 16:46:45 +0100 Subject: [PATCH 09/38] #14 Disable merge button after it has been clicked. This prevents users from clicking multiple times --- ui/app/scripts/controllers/case/CaseMergeModalCtrl.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js index e4336fa71a..e19b3585e6 100644 --- a/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js +++ b/ui/app/scripts/controllers/case/CaseMergeModalCtrl.js @@ -60,6 +60,7 @@ this.merge = function () { // TODO pass params as path params not query params + this.pendingAsync = true; CaseSrv.merge({}, { caseId: me.caze.id, mergedCaseId: me.search.cases[0].id @@ -73,6 +74,7 @@ AlertSrv.log('The cases have been successfully merged into a new case #' + merged.caseId, 'success'); }, function (response) { + this.pendingAsync = false; AlertSrv.error('CaseMergeModalCtrl', response.data, response.status); }); }; From 14d51476140c4910089191bed24225e413e2a628 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Fri, 18 Nov 2016 14:23:27 +0100 Subject: [PATCH 10/38] #14 Add visual hints indicating that a case has been closed as duplicate --- ui/app/scripts/services/Constants.js | 1 + ui/app/views/partials/case/case.panelinfo.html | 6 +++++- ui/app/views/partials/index-closedcases.html | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/app/scripts/services/Constants.js b/ui/app/scripts/services/Constants.js index 2bc9177d55..51e6794c6c 100644 --- a/ui/app/scripts/services/Constants.js +++ b/ui/app/scripts/services/Constants.js @@ -5,6 +5,7 @@ Indeterminate: 'Indeterminate', FalsePositive: 'False Positive', TruePositive: 'True Positive', + Duplicated: 'Duplicated', Other: 'Other' }) .value('CaseImpactStatus', { diff --git a/ui/app/views/partials/case/case.panelinfo.html b/ui/app/views/partials/case/case.panelinfo.html index 44741ee76e..0bb2cdb29c 100644 --- a/ui/app/views/partials/case/case.panelinfo.html +++ b/ui/app/views/partials/case/case.panelinfo.html @@ -57,6 +57,10 @@

- + + +
+

This case has been closed as Duplicated and merged into the following case

+
diff --git a/ui/app/views/partials/index-closedcases.html b/ui/app/views/partials/index-closedcases.html index 6602fbf656..5b0b814e2d 100644 --- a/ui/app/views/partials/index-closedcases.html +++ b/ui/app/views/partials/index-closedcases.html @@ -13,7 +13,10 @@ - #{{closedCase.caseId}} - {{closedCase.title}} + #{{closedCase.caseId}} - {{closedCase.title}} +
+ Merged into the following case +
From f06694cae787cb9f0c35807a5aeded68d359265a Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Fri, 18 Nov 2016 15:04:16 +0100 Subject: [PATCH 11/38] #10 refresh metrics cache when a new metric is created from the administration section --- ui/app/scripts/controllers/RootCtrl.js | 7 +++++++ ui/app/scripts/controllers/admin/AdminMetricsCtrl.js | 2 ++ ui/app/views/partials/case/case.details.html | 1 + 3 files changed, 10 insertions(+) diff --git a/ui/app/scripts/controllers/RootCtrl.js b/ui/app/scripts/controllers/RootCtrl.js index c3de017ee1..a500deac80 100644 --- a/ui/app/scripts/controllers/RootCtrl.js +++ b/ui/app/scripts/controllers/RootCtrl.js @@ -52,6 +52,13 @@ angular.module('theHiveControllers').controller('RootCtrl', AlertSrv.error('RootCtrl', data, status); }); + $scope.$on('metrics:refresh', function() { + // Get metrics cache + MetricsCacheSrv.all().then(function(list) { + $scope.metricsCache = list; + }); + }); + $scope.$on('misp:status-updated', function(event, enabled) { $scope.mispEnabled = enabled; }); diff --git a/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js b/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js index c85e378b3f..15ce6dfb3b 100644 --- a/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js @@ -35,6 +35,8 @@ $scope.initMetrics(); MetricsCacheSrv.clearCache(); + + $scope.$emit('metrics:refresh'); }, function(response) { AlertSrv.error('AdminMetricsCtrl', response.data, response.status); diff --git a/ui/app/views/partials/case/case.details.html b/ui/app/views/partials/case/case.details.html index 087c1c8f0b..9574c2a44c 100644 --- a/ui/app/views/partials/case/case.details.html +++ b/ui/app/views/partials/case/case.details.html @@ -134,6 +134,7 @@

+
No metrics need to be set
From d3b576eed93486f3fd676a131b76da8a53dbb60e Mon Sep 17 00:00:00 2001 From: To-om Date: Fri, 18 Nov 2016 15:17:15 +0100 Subject: [PATCH 12/38] #14 Update case summary with a reference of merged case --- .../app/services/CaseMergeSrv.scala | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/thehive-backend/app/services/CaseMergeSrv.scala b/thehive-backend/app/services/CaseMergeSrv.scala index e0470416c9..c7707b30a5 100644 --- a/thehive-backend/app/services/CaseMergeSrv.scala +++ b/thehive-backend/app/services/CaseMergeSrv.scala @@ -248,17 +248,20 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, caseSrv.create(fields) } - def markCaseAsDuplicated(caseIds: Seq[String], mergeCaseId: String)(implicit authContext: AuthContext): Future[Unit] = { - caseSrv.bulkUpdate(caseIds, Fields.empty - .set("mergeInto", mergeCaseId) - .set("status", CaseStatus.Resolved.toString) - .set("resolutionStatus", CaseResolutionStatus.Duplicated.toString)) - .map(_.foreach { - case Success(_) ⇒ Done - case Failure(error) ⇒ + def markCaseAsDuplicated(cases: Seq[Case], mergeCase: Case)(implicit authContext: AuthContext): Future[Done] = { + Future.traverse(cases) { caze ⇒ + caseSrv.update(caze.id, Fields.empty + .set("mergeInto", mergeCase.id) + .set("status", CaseStatus.Resolved.toString) + .set("resolutionStatus", CaseResolutionStatus.Duplicated.toString) + .set("summary", s"${caze.summary()}\n\nMerge into : ${mergeCase.title()} ([#${mergeCase.caseId()}](#/case/${mergeCase.id}/details))")) + } + .map(_ ⇒ Done) + .recover { + case error ⇒ log.error("Case update fail", error) Done - }) + } } def merge(caseIds: String*)(implicit authContext: AuthContext): Future[Case] = { @@ -267,7 +270,7 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, newCase ← mergeCases(cases) _ ← mergeTasksAndLogs(newCase, cases) _ ← mergeArtifactsAndJobs(newCase, cases) - _ ← markCaseAsDuplicated(caseIds, newCase.id) + _ ← markCaseAsDuplicated(cases, newCase) } yield newCase } } \ No newline at end of file From dbd4a19f34361ae1d4935971233bf155cd0807ef Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Fri, 18 Nov 2016 15:45:20 +0100 Subject: [PATCH 13/38] #18 Clean task and observable tabs when tasks or observables get deleted --- ui/app/scripts/controllers/case/CaseMainCtrl.js | 7 +++++++ ui/app/scripts/controllers/case/CaseObservablesCtrl.js | 6 ++++-- ui/app/scripts/controllers/case/CaseTasksCtrl.js | 4 +++- ui/app/scripts/services/CaseTabsSrv.js | 10 +++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ui/app/scripts/controllers/case/CaseMainCtrl.js b/ui/app/scripts/controllers/case/CaseMainCtrl.js index e7a9687afd..d37cfa6509 100644 --- a/ui/app/scripts/controllers/case/CaseMainCtrl.js +++ b/ui/app/scripts/controllers/case/CaseMainCtrl.js @@ -96,6 +96,13 @@ field: 'status' }); + $scope.$on('tasks:task-removed', function(event, task) { + CaseTabsSrv.removeTab('task-' + task.id); + }); + $scope.$on('observables:observable-removed', function(event, observable) { + CaseTabsSrv.removeTab('observable-' + observable.id); + }); + $scope.openTab = function(tabName) { var tab = CaseTabsSrv.getTab(tabName), params = angular.extend({}, $state.params, tab.params || {}); diff --git a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js index 62956f30d8..5048eb79ae 100644 --- a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js +++ b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js @@ -290,10 +290,12 @@ }; - $scope.dropArtifact = function(artifact) { + $scope.dropArtifact = function(observable) { // TODO check result ! CaseArtifactSrv.api().delete({ - artifactId: artifact.id + artifactId: observable.id + }, function() { + $scope.$emit('observables:observable-removed', observable); }); }; diff --git a/ui/app/scripts/controllers/case/CaseTasksCtrl.js b/ui/app/scripts/controllers/case/CaseTasksCtrl.js index 14b9d291c8..8dab80be60 100644 --- a/ui/app/scripts/controllers/case/CaseTasksCtrl.js +++ b/ui/app/scripts/controllers/case/CaseTasksCtrl.js @@ -71,7 +71,9 @@ 'taskId': task.id }, { status: 'Cancel' - }, function() {}, function(response) { + }, function() { + $scope.$emit('tasks:task-removed', task); + }, function(response) { AlertSrv.error('taskList', response.data, response.status); }); }); diff --git a/ui/app/scripts/services/CaseTabsSrv.js b/ui/app/scripts/services/CaseTabsSrv.js index b3002a1e22..17ca824830 100644 --- a/ui/app/scripts/services/CaseTabsSrv.js +++ b/ui/app/scripts/services/CaseTabsSrv.js @@ -59,15 +59,19 @@ }, removeTab: function(tab) { - var currentIsActive = tabs[tab].active; + var tabItem = tabs[tab]; + + if(!tabItem) { + return; + } + + var currentIsActive = tabItem.active; delete tabs[tab]; if (currentIsActive) { - console.log('Closing active tab, switch to details'); return true; } else { - console.log('Closing non active tab, stay in current tab'); return false; } From f63d7377bfdb075de64f1ba9af8d6bc175bb9849 Mon Sep 17 00:00:00 2001 From: To-om Date: Fri, 18 Nov 2016 16:28:52 +0100 Subject: [PATCH 14/38] #14 Add merge information in stats ; add nstats parameter in get case api call --- thehive-backend/app/controllers/Case.scala | 49 +++++---- thehive-backend/app/models/Case.scala | 111 +++++++++++++++------ 2 files changed, 110 insertions(+), 50 deletions(-) diff --git a/thehive-backend/app/controllers/Case.scala b/thehive-backend/app/controllers/Case.scala index 16d993d1f2..abf5f30d94 100644 --- a/thehive-backend/app/controllers/Case.scala +++ b/thehive-backend/app/controllers/Case.scala @@ -41,46 +41,51 @@ class CaseCtrl @Inject() ( val log = Logger(getClass) @Timed - def create() = authenticated(Role.write).async(fieldsBodyParser) { implicit request => + def create() = authenticated(Role.write).async(fieldsBodyParser) { implicit request ⇒ caseSrv.create(request.body) - .map(caze => renderer.toOutput(CREATED, caze)) + .map(caze ⇒ renderer.toOutput(CREATED, caze)) } @Timed - def get(id: String) = authenticated(Role.read).async { implicit request => - caseSrv.get(id) - .map(caze => renderer.toOutput(OK, caze)) + def get(id: String) = authenticated(Role.read).async(fieldsBodyParser) { implicit request ⇒ + val nparent = request.body.getLong("nparent").getOrElse(0L).toInt + val withStats = request.body.getBoolean("nstats").getOrElse(false) + + for { + caze ← caseSrv.get(id) + casesWithStats ← auxSrv.apply(caze, nparent, withStats) + } yield renderer.toOutput(OK, casesWithStats) } @Timed - def update(id: String) = authenticated(Role.write).async(fieldsBodyParser) { implicit request => + def update(id: String) = authenticated(Role.write).async(fieldsBodyParser) { implicit request ⇒ val isCaseClosing = request.body.getString("status").filter(_ == CaseStatus.Resolved.toString).isDefined for { // Closing the case, so lets close the open tasks - caze <- caseSrv.update(id, request.body) - closedTasks <- if (isCaseClosing) taskSrv.closeTasksOfCase(id) else Future.successful(Nil) // FIXME log warning if closedTasks contains errors + caze ← caseSrv.update(id, request.body) + closedTasks ← if (isCaseClosing) taskSrv.closeTasksOfCase(id) else Future.successful(Nil) // FIXME log warning if closedTasks contains errors } yield renderer.toOutput(OK, caze) } @Timed - def bulkUpdate() = authenticated(Role.write).async(fieldsBodyParser) { implicit request => + def bulkUpdate() = authenticated(Role.write).async(fieldsBodyParser) { implicit request ⇒ val isCaseClosing = request.body.getString("status").filter(_ == CaseStatus.Resolved.toString).isDefined - request.body.getStrings("ids").fold(Future.successful(Ok(JsArray()))) { ids => + request.body.getStrings("ids").fold(Future.successful(Ok(JsArray()))) { ids ⇒ if (isCaseClosing) taskSrv.closeTasksOfCase(ids: _*) // FIXME log warning if closedTasks contains errors - caseSrv.bulkUpdate(ids, request.body.unset("ids")).map(multiResult => renderer.toMultiOutput(OK, multiResult)) + caseSrv.bulkUpdate(ids, request.body.unset("ids")).map(multiResult ⇒ renderer.toMultiOutput(OK, multiResult)) } } @Timed - def delete(id: String) = authenticated(Role.write).async { implicit request => + def delete(id: String) = authenticated(Role.write).async { implicit request ⇒ caseSrv.delete(id) - .map(_ => NoContent) + .map(_ ⇒ NoContent) } @Timed - def find() = authenticated(Role.read).async(fieldsBodyParser) { implicit request => + def find() = authenticated(Role.read).async(fieldsBodyParser) { implicit request ⇒ val query = request.body.getValue("query").fold[QueryDef](QueryDSL.any)(_.as[QueryDef]) val range = request.body.getString("range") val sort = request.body.getStrings("sort").getOrElse(Nil) @@ -93,21 +98,21 @@ class CaseCtrl @Inject() ( } @Timed - def stats() = authenticated(Role.read).async(fieldsBodyParser) { implicit request => + def stats() = authenticated(Role.read).async(fieldsBodyParser) { implicit request ⇒ val query = request.body.getValue("query").fold[QueryDef](QueryDSL.any)(_.as[QueryDef]) val aggs = request.body.getValue("stats").getOrElse(throw BadRequestError("Parameter \"stats\" is missing")).as[Seq[Agg]] - caseSrv.stats(query, aggs).map(s => Ok(s)) + caseSrv.stats(query, aggs).map(s ⇒ Ok(s)) } @Timed - def linkedCases(id: String) = authenticated(Role.read).async { implicit request => + def linkedCases(id: String) = authenticated(Role.read).async { implicit request ⇒ caseSrv.linkedCases(id) .runWith(Sink.seq) - .map { cases => + .map { cases ⇒ val casesList = cases.sortWith { - case ((c1, _), (c2, _)) => c1.startDate().after(c2.startDate()) + case ((c1, _), (c2, _)) ⇒ c1.startDate().after(c2.startDate()) }.map { - case (caze, artifacts) => + case (caze, artifacts) ⇒ Json.toJson(caze).as[JsObject] - "description" + ("linkedWith" -> Json.toJson(artifacts)) + ("linksCount" -> Json.toJson(artifacts.size)) @@ -117,8 +122,8 @@ class CaseCtrl @Inject() ( } @Timed - def merge(caseId1: String, caseId2: String) = authenticated(Role.read).async { implicit request => - caseMergeSrv.merge(caseId1, caseId2).map { caze => + def merge(caseId1: String, caseId2: String) = authenticated(Role.read).async { implicit request ⇒ + caseMergeSrv.merge(caseId1, caseId2).map { caze ⇒ renderer.toOutput(OK, caze) } } diff --git a/thehive-backend/app/models/Case.scala b/thehive-backend/app/models/Case.scala index 91afb67620..dcc69e8634 100644 --- a/thehive-backend/app/models/Case.scala +++ b/thehive-backend/app/models/Case.scala @@ -13,11 +13,13 @@ import play.api.libs.json.Json import play.api.libs.json.Json.toJsFieldJsValueWrapper import org.elastic4play.JsonFormat.dateFormat -import org.elastic4play.models.{ AttributeDef, AttributeFormat => F, AttributeOption => O, BaseEntity, EntityDef, HiveEnumeration, ModelDef } +import org.elastic4play.models.{ AttributeDef, AttributeFormat ⇒ F, AttributeOption ⇒ O, BaseEntity, EntityDef, HiveEnumeration, ModelDef } import org.elastic4play.services.{ FindSrv, SequenceSrv } import JsonFormat.{ caseImpactStatusFormat, caseResolutionStatusFormat, caseStatusFormat } import services.AuditedModel +import services.CaseSrv +import play.api.Logger object CaseStatus extends Enumeration with HiveEnumeration { type Type = Value @@ -34,7 +36,7 @@ object CaseImpactStatus extends Enumeration with HiveEnumeration { val NoImpact, WithImpact, NotApplicable = Value } -trait CaseAttributes { _: AttributeDef => +trait CaseAttributes { _: AttributeDef ⇒ val caseId = attribute("caseId", F.numberFmt, "Id of the case (auto-generated)", O.model) val title = attribute("title", F.textFmt, "Title of the case") val description = attribute("description", F.textFmt, "Description of the case") @@ -50,58 +52,111 @@ trait CaseAttributes { _: AttributeDef => val resolutionStatus = optionalAttribute("resolutionStatus", F.enumFmt(CaseResolutionStatus), "Resolution status of the case") val impactStatus = optionalAttribute("impactStatus", F.enumFmt(CaseImpactStatus), "Impact status of the case") val summary = optionalAttribute("summary", F.textFmt, "Summary of the case, to be provided when closing a case") - val mergeInto = optionalAttribute("mergeInto",F.stringFmt, "Id of the case created by the merge") - val mergeFrom = multiAttribute("mergeFrom",F.stringFmt, "Id of the cases merged") + val mergeInto = optionalAttribute("mergeInto", F.stringFmt, "Id of the case created by the merge") + val mergeFrom = multiAttribute("mergeFrom", F.stringFmt, "Id of the cases merged") } @Singleton class CaseModel @Inject() ( artifactModel: Provider[ArtifactModel], taskModel: Provider[TaskModel], + caseSrv: Provider[CaseSrv], sequenceSrv: SequenceSrv, findSrv: FindSrv, - implicit val ec: ExecutionContext) extends ModelDef[CaseModel, Case]("case") with CaseAttributes with AuditedModel { caseModel => + implicit val ec: ExecutionContext) extends ModelDef[CaseModel, Case]("case") with CaseAttributes with AuditedModel { caseModel ⇒ + + lazy val logger = Logger(getClass) override val defaultSortBy = Seq("-startDate") override val removeAttribute = Json.obj("status" -> CaseStatus.Deleted) override def creationHook(parent: Option[BaseEntity], attrs: JsObject) = { - sequenceSrv("case").map { caseId => + sequenceSrv("case").map { caseId ⇒ attrs + ("caseId" -> JsNumber(caseId)) } } override def updateHook(entity: BaseEntity, updateAttrs: JsObject): Future[JsObject] = Future.successful { (updateAttrs \ "status").asOpt[CaseStatus.Type] match { - case Some(CaseStatus.Resolved) if !updateAttrs.keys.contains("endDate") => + case Some(CaseStatus.Resolved) if !updateAttrs.keys.contains("endDate") ⇒ updateAttrs + ("endDate" -> Json.toJson(new Date)) - case Some(CaseStatus.Open) => + case Some(CaseStatus.Open) ⇒ updateAttrs + ("endDate" -> JsArray(Nil)) - case _ => + case _ ⇒ updateAttrs } } - override def getStats(entity: BaseEntity): Future[JsObject] = { + private[models] def buildArtifactStats(caze: Case): Future[JsObject] = { import org.elastic4play.services.QueryDSL._ - for { - taskStatsJson <- findSrv( - taskModel.get, - and( - "_parent" ~= entity.id, - "status" in ("Waiting", "InProgress", "Completed")), - groupByField("status", selectCount)) - (taskCount, taskStats) = taskStatsJson.value.foldLeft((0L, JsObject(Nil))) { - case ((total, s), (key, value)) => - val count = (value \ "count").as[Long] - (total + count, s + (key -> JsNumber(count))) + findSrv( + artifactModel.get, + and( + parent("case", withId(caze.id)), + "status" ~= "Ok"), + selectCount) + .map { artifactStats ⇒ + Json.obj("artifacts" -> artifactStats) + } + } + + private[models] def buildTaskStats(caze: Case): Future[JsObject] = { + import org.elastic4play.services.QueryDSL._ + findSrv( + taskModel.get, + and( + parent("case", withId(caze.id)), + "status" in ("Waiting", "InProgress", "Completed")), + groupByField("status", selectCount)) + .map { taskStatsJson ⇒ + val (taskCount, taskStats) = taskStatsJson.value.foldLeft((0L, JsObject(Nil))) { + case ((total, s), (key, value)) ⇒ + val count = (value \ "count").as[Long] + (total + count, s + (key -> JsNumber(count))) + } + Json.obj("tasks" -> (taskStats + ("total" -> JsNumber(taskCount)))) + } + } + + private[models] def buildMergeIntoStats(caze: Case): Future[JsObject] = { + caze.mergeInto() + .fold(Future.successful(Json.obj())) { mergeCaseId ⇒ + caseSrv.get.get(mergeCaseId).map { c ⇒ + Json.obj("mergeInto" -> Json.obj( + "caseId" -> c.caseId(), + "title" -> c.title())) + } } - artifactStats <- findSrv( - artifactModel.get, - and( - "_parent" ~= entity.id, - "status" ~= "Ok"), - selectCount) - } yield Json.obj("tasks" -> (taskStats + ("total" -> JsNumber(taskCount))), "artifacts" -> artifactStats) + } + + private[models] def buildMergeFromStats(caze: Case): Future[JsObject] = { + Future + .traverse(caze.mergeFrom()) { id ⇒ + caseSrv.get.get(id).map { c ⇒ + Json.obj( + "caseId" -> c.caseId(), + "title" -> c.title()) + } + } + .map { + case mf if !mf.isEmpty ⇒ Json.obj("mergeFrom" -> mf) + case _ ⇒ Json.obj() + } + } + override def getStats(entity: BaseEntity): Future[JsObject] = { + + + entity match { + case caze: Case ⇒ + for { + taskStats <- buildTaskStats(caze) + artifactStats <- buildArtifactStats(caze) + mergeIntoStats <- buildMergeIntoStats(caze) + mergeFromStats <- buildMergeFromStats(caze) + } yield taskStats ++ artifactStats ++ mergeIntoStats ++ mergeFromStats + case other ⇒ + logger.warn(s"Request caseStats from a non-case entity ?! ${other.getClass}:$other") + Future.successful(Json.obj()) + } } override val computedMetrics = Map( From 87f0c4b175c1e1734287072eb4ac8c917b93e80d Mon Sep 17 00:00:00 2001 From: To-om Date: Fri, 18 Nov 2016 17:03:27 +0100 Subject: [PATCH 15/38] #14 Fix get nstats parameter (bodyParser doesn't work in GET request) --- thehive-backend/app/controllers/Case.scala | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/thehive-backend/app/controllers/Case.scala b/thehive-backend/app/controllers/Case.scala index abf5f30d94..41a219dd05 100644 --- a/thehive-backend/app/controllers/Case.scala +++ b/thehive-backend/app/controllers/Case.scala @@ -25,6 +25,7 @@ import org.elastic4play.services.JsonFormat.{ aggReads, queryReads } import models.{ Case, CaseStatus } import services.{ CaseSrv, TaskSrv } import services.CaseMergeSrv +import scala.util.Try @Singleton class CaseCtrl @Inject() ( @@ -47,13 +48,15 @@ class CaseCtrl @Inject() ( } @Timed - def get(id: String) = authenticated(Role.read).async(fieldsBodyParser) { implicit request ⇒ - val nparent = request.body.getLong("nparent").getOrElse(0L).toInt - val withStats = request.body.getBoolean("nstats").getOrElse(false) + def get(id: String) = authenticated(Role.read).async { implicit request ⇒ + val withStats = for { + statsValues <- request.queryString.get("nstats") + firstValue <- statsValues.headOption + } yield Try(firstValue.toBoolean).getOrElse(firstValue == "1") for { caze ← caseSrv.get(id) - casesWithStats ← auxSrv.apply(caze, nparent, withStats) + casesWithStats ← auxSrv.apply(caze, 0, withStats.getOrElse(false)) } yield renderer.toOutput(OK, casesWithStats) } From 95c34e7093bbc123e18a2e263f94632905c3a78f Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Fri, 18 Nov 2016 17:17:10 +0100 Subject: [PATCH 16/38] #14 display merged case title, in case details page for cases closed as duplicate --- ui/app/scripts/app.js | 3 ++- ui/app/views/partials/case/case.panelinfo.html | 18 +++++++++--------- ui/app/views/partials/index-closedcases.html | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ui/app/scripts/app.js b/ui/app/scripts/app.js index 44d75a8a35..e1e3e93c15 100644 --- a/ui/app/scripts/app.js +++ b/ui/app/scripts/app.js @@ -130,7 +130,8 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ui.bootstrap', 'ui.router var deferred = $q.defer(); CaseSrv.get({ - 'caseId': $stateParams.caseId + 'caseId': $stateParams.caseId, + 'nstats': true }, function(data) { deferred.resolve(data); diff --git a/ui/app/views/partials/case/case.panelinfo.html b/ui/app/views/partials/case/case.panelinfo.html index 0bb2cdb29c..e17da5c8b7 100644 --- a/ui/app/views/partials/case/case.panelinfo.html +++ b/ui/app/views/partials/case/case.panelinfo.html @@ -14,18 +14,18 @@

- + - + - + @@ -40,16 +40,14 @@

- {{caze.startDate | showDate}}   - (Closed at + (Closed at {{caze.endDate | showDate}} as {{CaseResolutionStatus[caze.resolutionStatus]}}) - @@ -57,10 +55,12 @@

- + -
-

This case has been closed as Duplicated and merged into the following case

+

+ This case has been closed as a duplicate and merged into
+ +

diff --git a/ui/app/views/partials/index-closedcases.html b/ui/app/views/partials/index-closedcases.html index 5b0b814e2d..17bf2dc6e2 100644 --- a/ui/app/views/partials/index-closedcases.html +++ b/ui/app/views/partials/index-closedcases.html @@ -15,7 +15,7 @@ #{{closedCase.caseId}} - {{closedCase.title}} From cca0d050e57046c3916e96ecb73b275631c7a7b5 Mon Sep 17 00:00:00 2001 From: Toom Date: Sun, 20 Nov 2016 10:58:22 +0100 Subject: [PATCH 17/38] #14 Fix summary in duplicate cases (its value is optional) --- thehive-backend/app/services/CaseMergeSrv.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/thehive-backend/app/services/CaseMergeSrv.scala b/thehive-backend/app/services/CaseMergeSrv.scala index c7707b30a5..4406cf1a57 100644 --- a/thehive-backend/app/services/CaseMergeSrv.scala +++ b/thehive-backend/app/services/CaseMergeSrv.scala @@ -250,11 +250,13 @@ class CaseMergeSrv @Inject() (caseSrv: CaseSrv, def markCaseAsDuplicated(cases: Seq[Case], mergeCase: Case)(implicit authContext: AuthContext): Future[Done] = { Future.traverse(cases) { caze ⇒ + val s = s"Merge into : ${mergeCase.title()} ([#${mergeCase.caseId()}](#/case/${mergeCase.id}/details))" + val summary = caze.summary().fold(s)(_ + s"\n\n$s") caseSrv.update(caze.id, Fields.empty .set("mergeInto", mergeCase.id) .set("status", CaseStatus.Resolved.toString) .set("resolutionStatus", CaseResolutionStatus.Duplicated.toString) - .set("summary", s"${caze.summary()}\n\nMerge into : ${mergeCase.title()} ([#${mergeCase.caseId()}](#/case/${mergeCase.id}/details))")) + .set("summary", summary)) } .map(_ ⇒ Done) .recover { From 3a188ee4934a26b637707224067ab4cf253f257d Mon Sep 17 00:00:00 2001 From: To-om Date: Mon, 21 Nov 2016 10:59:30 +0100 Subject: [PATCH 18/38] #14 Don't run analyzer on case merging --- thehive-backend/app/services/JobSrv.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/thehive-backend/app/services/JobSrv.scala b/thehive-backend/app/services/JobSrv.scala index bde442797d..d5153da094 100644 --- a/thehive-backend/app/services/JobSrv.scala +++ b/thehive-backend/app/services/JobSrv.scala @@ -67,8 +67,7 @@ class JobSrv(analyzerConf: JsValue, def create(artifact: Artifact, fields: Fields)(implicit authContext: AuthContext): Future[Job] = { createSrv[JobModel, Job, Artifact](jobModel, artifact, fields.set("artifactId", artifact.id)).map { - case job if job.status() != JobStatus.InProgress => job - case job => + case job if job.status() == JobStatus.InProgress => val newJob = for { analyzer <- analyzerSrv.get(job.analyzerId()) (status, result) <- analyzer.analyze(attachmentSrv, artifact) @@ -83,6 +82,7 @@ class JobSrv(analyzerConf: JsValue, case t => log.error("Job execution fail", t) } job + case job => job } } @@ -122,4 +122,4 @@ class JobSrv(analyzerConf: JsValue, } def stats(queryDef: QueryDef, agg: Agg) = findSrv(jobModel, queryDef, agg) -} \ No newline at end of file +} From cfd3845ed414426c10a87c4a1a5dfb51f9569928 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 21 Nov 2016 11:23:47 +0100 Subject: [PATCH 19/38] #14 Fix a typo --- ui/app/scripts/services/Constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/scripts/services/Constants.js b/ui/app/scripts/services/Constants.js index 51e6794c6c..f68fbf4d45 100644 --- a/ui/app/scripts/services/Constants.js +++ b/ui/app/scripts/services/Constants.js @@ -5,7 +5,7 @@ Indeterminate: 'Indeterminate', FalsePositive: 'False Positive', TruePositive: 'True Positive', - Duplicated: 'Duplicated', + Duplicated: 'Duplicate', Other: 'Other' }) .value('CaseImpactStatus', { From e994a8b82bd1fb274c61a653b5d318b3e04e2245 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 21 Nov 2016 11:48:33 +0100 Subject: [PATCH 20/38] #14 Add merge summary in to the open cases list --- ui/app/styles/case.css | 4 ++++ ui/app/views/partials/index-closedcases.html | 4 ++-- ui/app/views/partials/index-currentcases.html | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/app/styles/case.css b/ui/app/styles/case.css index c4dda5dccf..517890f6b6 100644 --- a/ui/app/styles/case.css +++ b/ui/app/styles/case.css @@ -23,6 +23,10 @@ span.link-id { text-align: center; } +.merge-hints { + padding-left: 35px; +} + .indicent-header h2.background { position: relative; z-index: 1; diff --git a/ui/app/views/partials/index-closedcases.html b/ui/app/views/partials/index-closedcases.html index 17bf2dc6e2..6687192f8c 100644 --- a/ui/app/views/partials/index-closedcases.html +++ b/ui/app/views/partials/index-closedcases.html @@ -14,8 +14,8 @@ #{{closedCase.caseId}} - {{closedCase.title}} -
- Merged into case #{{closedCase.stats.mergeInto.caseId}} + diff --git a/ui/app/views/partials/index-currentcases.html b/ui/app/views/partials/index-currentcases.html index ab790d6712..2653f92004 100644 --- a/ui/app/views/partials/index-currentcases.html +++ b/ui/app/views/partials/index-currentcases.html @@ -16,6 +16,12 @@ #{{currentCase.caseId}} - {{currentCase.title}} + From 5f3008cb6f4374c0cc80a670bcc87c014d2a33c7 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 21 Nov 2016 15:11:20 +0100 Subject: [PATCH 21/38] Add changelog file --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..11139f499a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log + +## [2.9.1](https://github.com/CERT-BDF/TheHive/tree/2.9.1) + +**Fixed bugs:** + +- Phantom tabs [\#18](https://github.com/CERT-BDF/TheHive/issues/18) +- The Action button of observables list is blank [\#15](https://github.com/CERT-BDF/TheHive/issues/15) +- Description becomes empty when you cancel an edition [\#13](https://github.com/CERT-BDF/TheHive/issues/13) +- Metric Labels Not Showing in Case View [\#10](https://github.com/CERT-BDF/TheHive/issues/10) +- chrome on os x - header alignment [\#5](https://github.com/CERT-BDF/TheHive/issues/5) +- Tags not saving when creating observable. [\#4](https://github.com/CERT-BDF/TheHive/issues/4) + +**Closed issues:** + +- Observable Viewing Page [\#17](https://github.com/CERT-BDF/TheHive/issues/17) +- Case merging [\#14](https://github.com/CERT-BDF/TheHive/issues/14) +- Give us something to work with! [\#2](https://github.com/CERT-BDF/TheHive/issues/2) + +**Merged pull requests:** + +- Fix "Run from Docker" [\#9](https://github.com/CERT-BDF/TheHive/pull/9) ([2xyo](https://github.com/2xyo)) +- Fixing a Simple Typo [\#6](https://github.com/CERT-BDF/TheHive/pull/6) ([swannysec](https://github.com/swannysec)) +- Fixed broken link to Wiki [\#1](https://github.com/CERT-BDF/TheHive/pull/1) ([Neo23x0](https://github.com/Neo23x0)) + + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file From 09191bdfc47189f4c69bf512bacc1878907c5e16 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 21 Nov 2016 15:33:02 +0100 Subject: [PATCH 22/38] Bump versions to 2.9.1 --- project/BuildSettings.scala | 2 +- project/Dependencies.scala | 2 +- ui/bower.json | 2 +- ui/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 0df7f3744b..b5c0d92356 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -7,7 +7,7 @@ object BasicSettings extends AutoPlugin { override def projectSettings = Seq( organization := "org.cert-bdf", licenses += "AGPL-V3" -> url("https://www.gnu.org/licenses/agpl-3.0.html"), - version := "2.9.1-SNAPSHOT", + version := "2.9.1", resolvers += Resolver.bintrayRepo("cert-bdf", "elastic4play"), scalaVersion := Dependencies.scalaVersion, scalacOptions ++= Seq( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index aeffdb083e..45555525e1 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -29,7 +29,7 @@ object Dependencies { val reflections = "org.reflections" % "reflections" % "0.9.10" val zip4j = "net.lingala.zip4j" % "zip4j" % "1.3.2" val akkaTest = "com.typesafe.akka" %% "akka-stream-testkit" % "2.4.4" - val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.1.1-AIV-SNAPSHOT" + val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.1.1" object Elastic4s { private val version = "2.3.0" diff --git a/ui/bower.json b/ui/bower.json index 7091646658..2ea4e4ab1b 100644 --- a/ui/bower.json +++ b/ui/bower.json @@ -1,6 +1,6 @@ { "name": "thehive", - "version": "2.9.1-SNAPSHOT", + "version": "2.9.1", "license": "AGPL-3.0", "dependencies": { "angular": "^1.5.8", diff --git a/ui/package.json b/ui/package.json index 3d691964e6..e0e7d65c44 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "thehive", - "version": "2.9.1-SNAPSHOT", + "version": "2.9.1", "license": "AGPL-3.0", "repository": { "type": "git", From e0f0a4b7f3527d2a88bc4e3112cfcef53b73872e Mon Sep 17 00:00:00 2001 From: To-om Date: Mon, 21 Nov 2016 18:03:37 +0100 Subject: [PATCH 23/38] #22 Don't update imported case from MISP if it is deleted or merged --- .../app/connectors/misp/MispSrv.scala | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/thehive-misp/app/connectors/misp/MispSrv.scala b/thehive-misp/app/connectors/misp/MispSrv.scala index 054be87e26..a4ea47251d 100644 --- a/thehive-misp/app/connectors/misp/MispSrv.scala +++ b/thehive-misp/app/connectors/misp/MispSrv.scala @@ -36,7 +36,7 @@ import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.model.FileHeader import JsonFormat.{ attributeReads, eventReads, eventStatusFormat, eventWrites } -import models.{ Artifact, Case, CaseModel, CaseStatus } +import models.{ Artifact, Case, CaseModel, CaseStatus, CaseResolutionStatus } import services.{ ArtifactSrv, CaseSrv, CaseTemplateSrv, UserSrv } case class MispInstanceConfig(name: String, url: String, key: String, caseTemplate: Option[String], artifactTags: Seq[String]) @@ -265,6 +265,22 @@ class MispSrv @Inject() (mispConfig: MispConfig, } } + def getSuccessMispAndCase(misp: Seq[Try[Misp]]): Future[Seq[(Misp, Case)]] = { + val successMisp = misp.collect { + case Success(m) => m + } + Future + .traverse(successMisp) { misp => + caseSrv.get(misp.id).map(misp -> _) + } + // remove deleted and merged cases + .map { + _.filter { + case (misp, caze) => caze.status() != CaseStatus.Deleted && caze.resolutionStatus != CaseResolutionStatus.Duplicated + } + } + } + /* for all misp servers, retrieve events */ getEvents /* sort events into : case must be updated (Left) and case must be created (Right) */ @@ -276,15 +292,14 @@ class MispSrv @Inject() (mispConfig: MispConfig, updatedMisp <- updateSrv(updates) createdMisp <- createSrv[MispModel, Misp](mispModel, creates) misp = updatedMisp ++ createdMisp - importedMisp = updatedMisp.collect { - case Success(m) if m.caze().isDefined => m - } + importedMisp <- getSuccessMispAndCase(updatedMisp) // update case status - _ <- updateSrv.apply[CaseModel, Case](caseModel, importedMisp.flatMap(_.caze()), Fields.empty.set("status", CaseStatus.Open.toString)) + _ <- caseSrv.bulkUpdate(importedMisp.map(_._2.id), Fields.empty.set("status", CaseStatus.Open.toString)) // and import MISP attributes - _ <- Future.sequence(importedMisp.map { m => - importAttributes(m).fallbackTo(Future.successful(Nil)) - }) + _ ← Future.traverse(importedMisp) { + case (m, c) => + importAttributes(m, c).fallbackTo(Future.successful(Nil)) + } } yield misp } } From b7bcd924c780c2f2b2419e4ec72bea1f3be8e48b Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Mon, 21 Nov 2016 23:44:33 -0600 Subject: [PATCH 24/38] New analyzer to check URL categories --- .../URLCategory/report/success_long.html | 18 ++++ .../URLCategory/report/success_short.html | 4 + analyzers/URLCategory/urlcategory.py | 85 +++++++++++++++++++ analyzers/URLCategory_1.0.json | 13 +++ 4 files changed, 120 insertions(+) create mode 100644 analyzers/URLCategory/report/success_long.html create mode 100644 analyzers/URLCategory/report/success_short.html create mode 100644 analyzers/URLCategory/urlcategory.py create mode 100644 analyzers/URLCategory_1.0.json diff --git a/analyzers/URLCategory/report/success_long.html b/analyzers/URLCategory/report/success_long.html new file mode 100644 index 0000000000..a48ebbb6da --- /dev/null +++ b/analyzers/URLCategory/report/success_long.html @@ -0,0 +1,18 @@ +
+
+ URL Categories of {{artifact.data}} +
+
+
+
Fortinet URL Category:
+
{{content.fortinet_category}}  + + + View Full Report + + + Request Recategorization +
+
+
+
diff --git a/analyzers/URLCategory/report/success_short.html b/analyzers/URLCategory/report/success_short.html new file mode 100644 index 0000000000..bc42a51644 --- /dev/null +++ b/analyzers/URLCategory/report/success_short.html @@ -0,0 +1,4 @@ + + URLCat: + {{content.fortinet_category}}  + diff --git a/analyzers/URLCategory/urlcategory.py b/analyzers/URLCategory/urlcategory.py new file mode 100644 index 0000000000..5d8fd4463a --- /dev/null +++ b/analyzers/URLCategory/urlcategory.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# encoding: utf-8 +import sys +import os +import json +import codecs +import time +import re +import requests + +if sys.stdout.encoding != 'UTF-8': + if sys.version_info.major == 3: + sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') + else: + sys.stdout = codecs.getwriter('utf-8')(sys.stdout, 'strict') +if sys.stderr.encoding != 'UTF-8': + if sys.version_info.major == 3: + sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') + else: + sys.stderr = codecs.getwriter('utf-8')(sys.stderr, 'strict') + +# load artifact +artifact = json.load(sys.stdin) + +def error(message): + print('{{"errorMessage":"{}"}}'.format(message)) + sys.exit(1) + +def get_param(name, default=None, message=None, current=artifact): + if isinstance(name, str): + name = name.split('.') + if len(name) == 0: + return current + else: + value = current.get(name[0]) + if value == None: + if message != None: + error(message) + else: + return default + else: + return get_param(name[1:], default, message, value) + +def debug(msg): + #print >> sys.stderr, msg + pass + +def fortinet_category(data): + debug('>> fortinet_category ' + str(data)) + pattern = re.compile("(?:Category: )([\w\s]+)") + baseurl = 'http://www.fortiguard.com/iprep?data=' + tailurl = '&lookup=Lookup' + url = baseurl + data + tailurl + r = requests.get(url) + category_match = re.search(pattern, r.content, flags=0) + return category_match.group(1) + +http_proxy = get_param('config.proxy.http') +https_proxy = get_param('config.proxy.https') +max_tlp = get_param('config.max_tlp', 1) +tlp = get_param('tlp', 2) # amber by default +data_type = get_param('dataType', None, 'Missing dataType field') +service = get_param('config.service', None, 'Service parameter is missing') + +# run only if TLP condition is met +if tlp > max_tlp: + error('Error with TLP value ; see max_tlp in config or tlp value in input data') + +# setup proxy +if http_proxy != None: + os.environ['http_proxy'] = http_proxy +if https_proxy != None: + os.environ['https_proxy'] = https_proxy + +if service == 'query': + if data_type == 'url' or data_type == 'domain': + data = get_param('data', None, 'Data is missing') + json.dump({ + 'fortinet_category': fortinet_category(data) + }, sys.stdout, ensure_ascii=False) + else: + error('Invalid data type') +else: + error('Invalid service') + diff --git a/analyzers/URLCategory_1.0.json b/analyzers/URLCategory_1.0.json new file mode 100644 index 0000000000..a98d46127a --- /dev/null +++ b/analyzers/URLCategory_1.0.json @@ -0,0 +1,13 @@ +{ + "name": "URLCategory", + "version": "1.0", + "report": "URLCategory/report", + "description": "URL Category query: checks the category of a specific URL or domain", + "dataTypeList": ["url", "domain"], + "baseConfig" : "URLCategory", + "config": { + "service": "query", + "max_tlp": 10 + }, + "command": "URLCategory/urlcategory.py" +} From ee9c2023760ecea9814e89b62372fb5a85aa7ebe Mon Sep 17 00:00:00 2001 From: To-om Date: Tue, 22 Nov 2016 12:12:34 +0100 Subject: [PATCH 25/38] #25 Fix MISP event parser to accept null attribute_count Don't import MISP event if it doesn't contain attribute --- thehive-misp/app/connectors/misp/JsonFormat.scala | 2 +- thehive-misp/app/connectors/misp/MispSrv.scala | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/thehive-misp/app/connectors/misp/JsonFormat.scala b/thehive-misp/app/connectors/misp/JsonFormat.scala index 87c63697e0..ad59cf0cce 100644 --- a/thehive-misp/app/connectors/misp/JsonFormat.scala +++ b/thehive-misp/app/connectors/misp/JsonFormat.scala @@ -20,7 +20,7 @@ object JsonFormat { eventId <- (json \ "id").validate[String] optTags <- (json \ "EventTag").validateOpt[Seq[JsValue]] tags = optTags.toSeq.flatten.flatMap(t => (t \ "Tag" \ "name").asOpt[String]) - attrCountStr <- (json \ "attribute_count").validate[String] + attrCountStr <- (json \ "attribute_count").validate[String].recover { case _ => "0" } attrCount = attrCountStr.toInt timestamp <- (json \ "timestamp").validate[String] date = new Date(timestamp.toLong * 1000) diff --git a/thehive-misp/app/connectors/misp/MispSrv.scala b/thehive-misp/app/connectors/misp/MispSrv.scala index a4ea47251d..7e65ebabec 100644 --- a/thehive-misp/app/connectors/misp/MispSrv.scala +++ b/thehive-misp/app/connectors/misp/MispSrv.scala @@ -131,13 +131,13 @@ class MispSrv @Inject() (mispConfig: MispConfig, */ def getEvents: Future[Seq[MispEvent]] = { Future - .sequence(mispConfig.instances.map { c => + .traverse(mispConfig.instances) { c => getEvents(c).recoverWith { case t => log.warn("Retrieve MISP event list failed", t) Future.failed(t) } - }) + } .map(_.flatten) } @@ -150,10 +150,13 @@ class MispSrv @Inject() (mispConfig: MispConfig, .asOpt[Seq[JsValue]] .getOrElse(Nil) val events = eventJson.flatMap { j => - j.asOpt[MispEvent].map(_.copy(serverId = instanceConfig.name)) orElse { - log.warn(s"MISP event can't be parsed\n$j") - None - } + j.asOpt[MispEvent] + .map(_.copy(serverId = instanceConfig.name)) + .orElse { + log.warn(s"MISP event can't be parsed\n$j") + None + } + .filter(_.attributeCount > 0) } val eventJsonSize = eventJson.size val eventsSize = events.size From d2ea68627ca18045bbf47bcce8243ebf76175e8b Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Tue, 22 Nov 2016 14:11:24 +0100 Subject: [PATCH 26/38] #24 Add execution permissions to the analyzer's launcher script --- analyzers/URLCategory/urlcategory.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 analyzers/URLCategory/urlcategory.py diff --git a/analyzers/URLCategory/urlcategory.py b/analyzers/URLCategory/urlcategory.py old mode 100644 new mode 100755 From e7afa3be057946637427e0b44a14d5c715ded943 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Tue, 22 Nov 2016 14:55:47 +0100 Subject: [PATCH 27/38] #23 Remove hardcoded reference to french names of maxmind locations --- analyzers/MaxMind/report/success_short.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analyzers/MaxMind/report/success_short.html b/analyzers/MaxMind/report/success_short.html index 2289ffa02c..b0b410cbdb 100644 --- a/analyzers/MaxMind/report/success_short.html +++ b/analyzers/MaxMind/report/success_short.html @@ -1 +1 @@ -IP location: {{content.country.names.fr}} / {{content.continent.names.fr}} \ No newline at end of file +IP location: {{content.country.name}} / {{content.continent.name}} From e51bf6a5a61ac445af16a2b6d48d85a903c598c1 Mon Sep 17 00:00:00 2001 From: To-om Date: Wed, 23 Nov 2016 22:52:50 +0100 Subject: [PATCH 28/38] #29 Fix systemd init script TheHive user must have enough right to create PID file. So put it in installation directory. --- install/thehive.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/thehive.service b/install/thehive.service index 5ec6e60a21..a4329efcf4 100644 --- a/install/thehive.service +++ b/install/thehive.service @@ -12,7 +12,7 @@ Group=thehive ExecStart=/opt/thehive/bin/thehive \ -Dconfig.file=/etc/thehive/application.conf \ -Dhttp.port=9000 \ - -Dpidfile.path=/var/run/thehive/pid + -Dpidfile.path=/opt/thehive/RUNNING_PID StandardOutput=journal From 296a5dbc642dcbef75d737b75fab8a5be756d519 Mon Sep 17 00:00:00 2001 From: To-om Date: Thu, 24 Nov 2016 14:52:43 +0100 Subject: [PATCH 29/38] #41 Create an empty file in conf directory in order to include the directory in package --- conf/keepme | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 conf/keepme diff --git a/conf/keepme b/conf/keepme new file mode 100644 index 0000000000..e69de29bb2 From 441182a2b170fce0af1ae5da43bd70996f6a717f Mon Sep 17 00:00:00 2001 From: Saad Kadhi Date: Thu, 24 Nov 2016 23:02:20 +0100 Subject: [PATCH 30/38] Better wording and typo corrections --- ui/app/views/partials/case/case.close.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/app/views/partials/case/case.close.html b/ui/app/views/partials/case/case.close.html index 3f1603b2a6..4760e8b518 100644 --- a/ui/app/views/partials/case/case.close.html +++ b/ui/app/views/partials/case/case.close.html @@ -78,13 +78,13 @@

Incident

Investigation clearly demonstrates that there is something malicious (scam, phishing, malspam, malware, cybersquatting...)
- Investigation shows that there is nothing malicious (unlock email with clean attachment ...) + Investigation shows that there is nothing malicious (email with clean attachment ...) - There is not enough elements to tell that there is something malicious (original message has been delete and not transmitted, IOC lookup with 0 hit ...) + There aren't enough elements to tell that there is something malicious (original message has been deleted and not transmitted, IOC lookup with 0 hits ...) - Everything that does not need analysis (not an incident) + Everything that does not require an investigation (not an incident)

@@ -105,7 +105,7 @@

Incident

Something altered availability, integrity or confidentiality - Security measures blocked the attack on infection + Security measures blocked the attack or infection

This field is required

From 52a15fe386a43dace02e663f9f25348b47074735 Mon Sep 17 00:00:00 2001 From: Saad Kadhi Date: Sat, 26 Nov 2016 09:54:00 +0100 Subject: [PATCH 31/38] Add a new section for the contributed analyzers Eric Capuano has contributed URLCategory --- AUTHORS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AUTHORS b/AUTHORS index 27de4530b7..7db3412333 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,6 +11,11 @@ Contributors * CERT Banque de France (CERT-BDF) * Nabil Adouani +Contributed Analyzers +--------------------- + +* URLCategory: Eric Capuano + Copyright (C) 2014-2016 Thomas Franco Copyright (C) 2014-2016 Saâd Kadhi Copyright (C) 2014-2016 Jérôme Leonard From 6f551a2a95a947e4f7f8755f83d198ad8bd53093 Mon Sep 17 00:00:00 2001 From: Saad Kadhi Date: Sat, 26 Nov 2016 10:03:40 +0100 Subject: [PATCH 32/38] Add the URLCategory analyzer The URLCategory analyzer was contributed by Eric Capuano. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7597b15d4c..673d0e29f8 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,14 @@ TheHive is written in Scala and uses ElasticSearch 2.x for storage. Its REST API ![](images/Architecture.png) ## Analyzers -The first public release of TheHive is provided with 7 analyzers: +TheHive 2.9.1 is provided with 8 analyzers: + DNSDB*: leverage Farsight's [DNSDB](https://www.dnsdb.info/) for pDNS. + DomainTools*: look up domain names, IP addresses, WHOIS records, etc. using the popular [DomainTools](http://domaintools.com/) service API. + Hippocampe: query threat feeds through [Hippocampe](https://github.com/CERT-BDF/Hippocampe), a FOSS tool that centralizes feeds and allows you to associate a confidence level to each one of them (that can be changed over time) and get a score indicating the data quality. + MaxMind: geolocation. + Olevba: parse OLE and OpenXML files using [olevba](http://www.decalage.info/python/olevba) to detect VBA macros, extract their source code etc. + Outlook MsgParser: this analyzer allows to add an Outlook message file as an observable and parse it automatically. ++ URLCategory: checks the Fortinet categories of URLs. + VirusTotal*: look up files, URLs and hashes through [VirusTotal](https://www.virustotal.com/). The star (*) indicates that the analyzer needs an API key to work correctly. We do not provide API keys. You have to use your own. From 3b86445675af1e32d92217f1b54e45b1af7be3ce Mon Sep 17 00:00:00 2001 From: Saad Kadhi Date: Sat, 26 Nov 2016 12:30:48 +0100 Subject: [PATCH 33/38] Improve wording to avoid confusions --- ui/app/views/partials/admin/users.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/app/views/partials/admin/users.html b/ui/app/views/partials/admin/users.html index 443a86a238..134567eb9e 100644 --- a/ui/app/views/partials/admin/users.html +++ b/ui/app/views/partials/admin/users.html @@ -7,7 +7,7 @@

User management

- +
@@ -45,7 +45,7 @@

User management

Login - User name + Full Name Roles Password / API key Lock From 93ac9a4cbda11ce4033ae02f3a2c7f79f576b4bb Mon Sep 17 00:00:00 2001 From: Saad Kadhi Date: Sat, 26 Nov 2016 12:31:56 +0100 Subject: [PATCH 34/38] Improve wording to avoid confusions --- ui/app/views/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/views/login.html b/ui/app/views/login.html index 44f2932d97..8856b6a80b 100644 --- a/ui/app/views/login.html +++ b/ui/app/views/login.html @@ -6,7 +6,7 @@

{{alert.message}}
- +

From c96ddb0f7ab2983e0625f73c5b9f740e784cec36 Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 28 Nov 2016 10:48:11 +0100 Subject: [PATCH 35/38] Update change log for 2.9.1 release --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11139f499a..8083a3a9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,17 @@ ## [2.9.1](https://github.com/CERT-BDF/TheHive/tree/2.9.1) +**Implemented enhancements:** + +- Inconsistent wording between the login and user management pages [\#44](https://github.com/CERT-BDF/TheHive/issues/44) +- MaxMind Analyzer 'Short Report' has hard-coded language [\#23](https://github.com/CERT-BDF/TheHive/issues/23) +- Don't update imported case from MISP if it is deleted or merged [\#22](https://github.com/CERT-BDF/TheHive/issues/22) + **Fixed bugs:** +- NPE occurs at startup if conf directory doesn't exists [\#41](https://github.com/CERT-BDF/TheHive/issues/41) +- Resource not found by Assets controller [\#38](https://github.com/CERT-BDF/TheHive/issues/38) +- MISP event parsing error when it doesn't contain any attribute [\#25](https://github.com/CERT-BDF/TheHive/issues/25) - Phantom tabs [\#18](https://github.com/CERT-BDF/TheHive/issues/18) - The Action button of observables list is blank [\#15](https://github.com/CERT-BDF/TheHive/issues/15) - Description becomes empty when you cancel an edition [\#13](https://github.com/CERT-BDF/TheHive/issues/13) @@ -13,12 +22,15 @@ **Closed issues:** +- Statistics based on Tags [\#37](https://github.com/CERT-BDF/TheHive/issues/37) +- Statistics on a per case template name / prefix basis [\#31](https://github.com/CERT-BDF/TheHive/issues/31) - Observable Viewing Page [\#17](https://github.com/CERT-BDF/TheHive/issues/17) - Case merging [\#14](https://github.com/CERT-BDF/TheHive/issues/14) - Give us something to work with! [\#2](https://github.com/CERT-BDF/TheHive/issues/2) **Merged pull requests:** +- New analyzer to check URL categories [\#24](https://github.com/CERT-BDF/TheHive/pull/24) ([ecapuano](https://github.com/ecapuano)) - Fix "Run from Docker" [\#9](https://github.com/CERT-BDF/TheHive/pull/9) ([2xyo](https://github.com/2xyo)) - Fixing a Simple Typo [\#6](https://github.com/CERT-BDF/TheHive/pull/6) ([swannysec](https://github.com/swannysec)) - Fixed broken link to Wiki [\#1](https://github.com/CERT-BDF/TheHive/pull/1) ([Neo23x0](https://github.com/Neo23x0)) From 510de23bce9421b0a0ea3aebbd147b96cef6c07e Mon Sep 17 00:00:00 2001 From: Nabil Adouani Date: Mon, 28 Nov 2016 11:56:21 +0100 Subject: [PATCH 36/38] #45 Update the logo --- ui/app/images/favicon.png | Bin 1907 -> 2294 bytes ui/app/images/logo.png | Bin 5391 -> 18503 bytes ui/app/images/logo.white.png | Bin 3015 -> 2182 bytes ui/app/styles/main.css | 4 ++++ ui/app/views/app.html | 2 +- 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/app/images/favicon.png b/ui/app/images/favicon.png index 36906f912c8515bbc67ce6dc885180652fef5d7c..19340ab62c54df30ad2999322f075cb17b4850be 100644 GIT binary patch delta 1575 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 delta 2403 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 ahWCGGr8A})>|m_~img { display: inline; } + .main-navbar .nav>li>a { padding: 15px 8px; } diff --git a/ui/app/views/app.html b/ui/app/views/app.html index f98b09b390..8f518c8c16 100644 --- a/ui/app/views/app.html +++ b/ui/app/views/app.html @@ -9,7 +9,7 @@ - The Hive + The Hive