Skip to content

Commit

Permalink
#14 add initial support to case merging
Browse files Browse the repository at this point in the history
  • Loading branch information
nadouani committed Nov 16, 2016
1 parent db0c3f2 commit 53ee39b
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 13 deletions.
2 changes: 1 addition & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion thehive-backend/app/controllers/Case.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion thehive-backend/app/services/ArtifactSrv.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
221 changes: 221 additions & 0 deletions thehive-backend/app/services/CaseMergeSrv.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 1 addition & 2 deletions thehive-backend/app/services/CaseSrv.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ class CaseSrv @Inject() (
taskModel: TaskModel,
createSrv: CreateSrv,
artifactSrv: ArtifactSrv,
taskSrv: TaskSrv,
getSrv: GetSrv,
updateSrv: UpdateSrv,
deleteSrv: DeleteSrv,
Expand All @@ -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] =
Expand Down
1 change: 1 addition & 0 deletions thehive-backend/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions ui/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
<script src="scripts/controllers/case/CaseDetailsCtrl.js"></script>
<script src="scripts/controllers/case/CaseLinksCtrl.js"></script>
<script src="scripts/controllers/case/CaseMainCtrl.js"></script>
<script src="scripts/controllers/case/CaseMergeModalCtrl.js"></script>
<script src="scripts/controllers/case/CaseObservablesCtrl.js"></script>
<script src="scripts/controllers/case/CaseObservablesExportCtrl.js"></script>
<script src="scripts/controllers/case/CaseObservablesItemCtrl.js"></script>
Expand Down
14 changes: 14 additions & 0 deletions ui/app/scripts/controllers/case/CaseMainCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions ui/app/scripts/controllers/case/CaseMergeModalCtrl.js
Original file line number Diff line number Diff line change
@@ -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();
};
}
})();
8 changes: 8 additions & 0 deletions ui/app/scripts/services/CaseSrv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
}
});
});
Expand Down
Loading

0 comments on commit 53ee39b

Please sign in to comment.