diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbe50a355..ca5f608373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,36 @@ # Change Log +## [2.12.0](https://github.com/CERT-BDF/TheHive/tree/2.12.0) + +[Full Changelog](https://github.com/CERT-BDF/TheHive/compare/2.11.3...2.12.0) + +**Implemented enhancements:** + +- Sort the analyzers list in observable details page [\#245](https://github.com/CERT-BDF/TheHive/issues/245) +- More options to sort cases [\#243](https://github.com/CERT-BDF/TheHive/issues/243) +- Alert Preview and management improvements [\#232](https://github.com/CERT-BDF/TheHive/issues/232) +- Ability to Reopen Tasks [\#156](https://github.com/CERT-BDF/TheHive/issues/156) +- Display short reports on the Observables tab [\#131](https://github.com/CERT-BDF/TheHive/issues/131) +- Custom fields for case template [\#12](https://github.com/CERT-BDF/TheHive/issues/12) +- Show case status and category \(FP, TP, IND\) in related cases [\#229](https://github.com/CERT-BDF/TheHive/issues/229) +- Open External Links in New Tab [\#228](https://github.com/CERT-BDF/TheHive/issues/228) +- Observable analyzers view reports. [\#191](https://github.com/CERT-BDF/TheHive/issues/191) +- Specifying tags on statistics page or performing a search [\#186](https://github.com/CERT-BDF/TheHive/issues/186) +- Choose case template while importing events from MISP [\#175](https://github.com/CERT-BDF/TheHive/issues/175) +- Use local font files [\#250](https://github.com/CERT-BDF/TheHive/issues/250) + +**Fixed bugs:** + +- Fix case metrics malformed definitions [\#248](https://github.com/CERT-BDF/TheHive/issues/248) +- Sorting alerts by severity fails [\#242](https://github.com/CERT-BDF/TheHive/issues/242) +- Alerting Panel: Typo Correction [\#240](https://github.com/CERT-BDF/TheHive/issues/240) +- files in alerts are limited to 32kB [\#237](https://github.com/CERT-BDF/TheHive/issues/237) +- Alert can contain inconsistent data [\#234](https://github.com/CERT-BDF/TheHive/issues/234) +- Search do not work with non-latin characters [\#223](https://github.com/CERT-BDF/TheHive/issues/223) +- report status not updated after finish [\#212](https://github.com/CERT-BDF/TheHive/issues/212) + ## [2.11.3](https://github.com/CERT-BDF/TheHive/tree/2.11.3) (2017-06-14) -[Full Changelog](https://github.com/CERT-BDF/TheHive/compare/debian/2.11.2-2...2.11.3) +[Full Changelog](https://github.com/CERT-BDF/TheHive/compare/debian/2.11.2...2.11.3) **Fixed bugs:** diff --git a/build.sbt b/build.sbt index 36624fb200..247d46066a 100644 --- a/build.sbt +++ b/build.sbt @@ -104,7 +104,7 @@ packageBin := { (packageBin in Rpm).value } // DEB // -version in Debian := version.value + "-2" +version in Debian := version.value + "-1" debianPackageRecommends := Seq("elasticsearch") debianPackageDependencies += "openjdk-8-jre-headless" maintainerScripts in Debian := maintainerScriptsFromDirectory( diff --git a/contrib/report-templates/File_Info_1_0/long.html b/contrib/report-templates/File_Info_1_0/long.html index f78c3e991a..7d72d879d9 100644 --- a/contrib/report-templates/File_Info_1_0/long.html +++ b/contrib/report-templates/File_Info_1_0/long.html @@ -9,7 +9,7 @@
- File Idenfitication + File Identification
diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 93bd113b0c..129c4e93f0 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -30,6 +30,6 @@ 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.5" + val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.2.1" } } diff --git a/thehive-backend/app/controllers/AlertCtrl.scala b/thehive-backend/app/controllers/AlertCtrl.scala index b89d0d7f01..2aba98681d 100644 --- a/thehive-backend/app/controllers/AlertCtrl.scala +++ b/thehive-backend/app/controllers/AlertCtrl.scala @@ -10,9 +10,10 @@ import org.elastic4play.services._ import org.elastic4play.{ BadRequestError, Timed } import play.api.Logger import play.api.http.Status -import play.api.libs.json.JsArray +import play.api.libs.json.{ JsArray, JsObject, Json } import play.api.mvc.{ Action, AnyContent, Controller } -import services.AlertSrv +import services.{ AlertSrv, CaseSrv } +import services.JsonFormat.caseSimilarityWrites import scala.concurrent.{ ExecutionContext, Future } import scala.util.Try @@ -20,6 +21,7 @@ import scala.util.Try @Singleton class AlertCtrl @Inject() ( alertSrv: AlertSrv, + caseSrv: CaseSrv, auxSrv: AuxSrv, authenticated: Authenticated, renderer: Renderer, @@ -35,17 +37,39 @@ class AlertCtrl @Inject() ( .map(alert ⇒ renderer.toOutput(CREATED, alert)) } + @Timed + def mergeWithCase(alertId: String, caseId: String): Action[Fields] = authenticated(Role.write).async(fieldsBodyParser) { implicit request ⇒ + for { + alert ← alertSrv.get(alertId) + caze ← caseSrv.get(caseId) + _ ← alertSrv.mergeWithCase(alert, caze) + } yield renderer.toOutput(CREATED, caze) + } + @Timed def get(id: String): Action[AnyContent] = authenticated(Role.read).async { implicit request ⇒ - val withStats = for { - statsValues ← request.queryString.get("nstats") - firstValue ← statsValues.headOption - } yield Try(firstValue.toBoolean).getOrElse(firstValue == "1") + val withStats = request + .queryString + .get("nstats") + .flatMap(_.headOption) + .exists(v ⇒ Try(v.toBoolean).getOrElse(v == "1")) + + val withSimilarity = request + .queryString + .get("similarity") + .flatMap(_.headOption) + .exists(v ⇒ Try(v.toBoolean).getOrElse(v == "1")) for { alert ← alertSrv.get(id) - alertsWithStats ← auxSrv.apply(alert, 0, withStats.getOrElse(false), removeUnaudited = false) - } yield renderer.toOutput(OK, alertsWithStats) + alertsWithStats ← auxSrv.apply(alert, 0, withStats, removeUnaudited = false) + similarCases ← if (withSimilarity) + alertSrv.similarCases(alert) + .map(sc ⇒ Json.obj("similarCases" → Json.toJson(sc))) + else Future.successful(JsObject(Nil)) + } yield { + renderer.toOutput(OK, alertsWithStats ++ similarCases) + } } @Timed @@ -106,11 +130,12 @@ class AlertCtrl @Inject() ( } @Timed - def createCase(id: String): Action[AnyContent] = authenticated(Role.write).async { implicit request ⇒ + def createCase(id: String): Action[Fields] = authenticated(Role.write).async(fieldsBodyParser) { implicit request ⇒ for { alert ← alertSrv.get(id) - updatedAlert ← alertSrv.createCase(alert) - } yield renderer.toOutput(CREATED, updatedAlert) + customCaseTemplate = request.body.getString("caseTemplate") + caze ← alertSrv.createCase(alert, customCaseTemplate) + } yield renderer.toOutput(CREATED, caze) } @Timed diff --git a/thehive-backend/app/controllers/ArtifactCtrl.scala b/thehive-backend/app/controllers/ArtifactCtrl.scala index e31ce1318d..f6ac842c85 100644 --- a/thehive-backend/app/controllers/ArtifactCtrl.scala +++ b/thehive-backend/app/controllers/ArtifactCtrl.scala @@ -48,7 +48,7 @@ class ArtifactCtrl @Inject() ( @Timed def get(id: String): Action[Fields] = authenticated(Role.read).async(fieldsBodyParser) { implicit request ⇒ - artifactSrv.get(id, request.body.getStrings("fields").map("dataType" +: _)) + artifactSrv.get(id) .map(artifact ⇒ renderer.toOutput(OK, artifact)) } diff --git a/thehive-backend/app/models/Alert.scala b/thehive-backend/app/models/Alert.scala index c4f2a67e4c..63b35ec76f 100644 --- a/thehive-backend/app/models/Alert.scala +++ b/thehive-backend/app/models/Alert.scala @@ -4,8 +4,11 @@ import java.util.Date import javax.inject.{ Inject, Singleton } import models.JsonFormat.alertStatusFormat -import org.elastic4play.models.{ Attribute, AttributeDef, BaseEntity, EntityDef, HiveEnumeration, ModelDef, AttributeFormat ⇒ F, AttributeOption ⇒ O } +import org.elastic4play.controllers.JsonInputValue +import org.elastic4play.models.{ Attribute, AttributeDef, BaseEntity, EntityDef, HiveEnumeration, ModelDef, MultiAttributeFormat, OptionalAttributeFormat, AttributeFormat ⇒ F, AttributeOption ⇒ O } +import org.elastic4play.services.DBLists import org.elastic4play.utils.Hasher +import org.elastic4play.{ AttributeCheckingError, InvalidFormatAttributeError } import play.api.Logger import play.api.libs.json._ import services.AuditedModel @@ -20,7 +23,26 @@ object AlertStatus extends Enumeration with HiveEnumeration { trait AlertAttributes { _: AttributeDef ⇒ - def artifactAttributes: Seq[Attribute[_]] + val artifactAttributes: Seq[Attribute[_]] = { + val remoteAttachmentAttributes = Seq( + Attribute("alert", "reference", F.stringFmt, Nil, None, ""), + Attribute("alert", "filename", OptionalAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "contentType", OptionalAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "size", OptionalAttributeFormat(F.numberFmt), Nil, None, ""), + Attribute("alert", "hash", MultiAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "type", OptionalAttributeFormat(F.stringFmt), Nil, None, "")) + + Seq( + Attribute("alert", "data", OptionalAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "dataType", F.stringFmt, Nil, None, ""), + Attribute("alert", "message", OptionalAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "startDate", OptionalAttributeFormat(F.dateFmt), Nil, None, ""), + Attribute("alert", "attachment", OptionalAttributeFormat(F.attachmentFmt), Nil, None, ""), + Attribute("alert", "remoteAttachment", OptionalAttributeFormat(F.objectFmt(remoteAttachmentAttributes)), Nil, None, ""), + Attribute("alert", "tlp", OptionalAttributeFormat(F.numberFmt), Nil, None, ""), + Attribute("alert", "tags", MultiAttributeFormat(F.stringFmt), Nil, None, ""), + Attribute("alert", "ioc", OptionalAttributeFormat(F.stringFmt), Nil, None, "")) + } val alertId: A[String] = attribute("_id", F.stringFmt, "Alert id", O.readonly) val tpe: A[String] = attribute("type", F.stringFmt, "Type of the alert", O.readonly) @@ -41,7 +63,7 @@ trait AlertAttributes { } @Singleton -class AlertModel @Inject() (artifactModel: ArtifactModel) +class AlertModel @Inject() (dblists: DBLists) extends ModelDef[AlertModel, Alert]("alert") with AlertAttributes with AuditedModel { @@ -50,21 +72,31 @@ class AlertModel @Inject() (artifactModel: ArtifactModel) override val defaultSortBy: Seq[String] = Seq("-date") override val removeAttribute: JsObject = Json.obj("status" → AlertStatus.Ignored) - override def artifactAttributes: Seq[Attribute[_]] = artifactModel.attributes - override def creationHook(parent: Option[BaseEntity], attrs: JsObject): Future[JsObject] = { - Future.successful { - if (attrs.keys.contains("_id")) - attrs - else { - val hasher = Hasher("MD5") - val tpe = (attrs \ "tpe").asOpt[String].getOrElse("") - val source = (attrs \ "source").asOpt[String].getOrElse("") - val sourceRef = (attrs \ "sourceRef").asOpt[String].getOrElse("") - val _id = hasher.fromString(s"$tpe|$source|$sourceRef").head.toString() - attrs + ("_id" → JsString(_id)) - } - "lastSyncDate" - "case" - "status" - "follow" - } + // check if data attribute is present on all artifacts + val missingDataErrors = (attrs \ "artifacts") + .asOpt[Seq[JsValue]] + .getOrElse(Nil) + .filter { a ⇒ + ((a \ "data").toOption.isEmpty && (a \ "attachment").toOption.isEmpty && (a \ "remoteAttachment").toOption.isEmpty) || + ((a \ "tags").toOption.isEmpty && (a \ "message").toOption.isEmpty) + } + .map(v ⇒ InvalidFormatAttributeError("artifacts", "artifact", JsonInputValue(v))) + if (missingDataErrors.nonEmpty) + Future.failed(AttributeCheckingError("alert", missingDataErrors)) + else + Future.successful { + if (attrs.keys.contains("_id")) + attrs + else { + val hasher = Hasher("MD5") + val tpe = (attrs \ "tpe").asOpt[String].getOrElse("") + val source = (attrs \ "source").asOpt[String].getOrElse("") + val sourceRef = (attrs \ "sourceRef").asOpt[String].getOrElse("") + val _id = hasher.fromString(s"$tpe|$source|$sourceRef").head.toString() + attrs + ("_id" → JsString(_id)) + } - "lastSyncDate" - "case" - "status" - "follow" + } } } @@ -72,8 +104,6 @@ class Alert(model: AlertModel, attributes: JsObject) extends EntityDef[AlertModel, Alert](model, attributes) with AlertAttributes { - override def artifactAttributes: Seq[Attribute[_]] = Nil - override def toJson: JsObject = super.toJson + ("artifacts" → JsArray(artifacts().map { // for file artifact, parse data as Json diff --git a/thehive-backend/app/models/Artifact.scala b/thehive-backend/app/models/Artifact.scala index 6d613398e2..379c2ec931 100644 --- a/thehive-backend/app/models/Artifact.scala +++ b/thehive-backend/app/models/Artifact.scala @@ -3,21 +3,26 @@ package models import java.util.Date import javax.inject.{ Inject, Provider, Singleton } +import akka.{ Done, NotUsed } + import scala.concurrent.{ ExecutionContext, Future } import scala.language.postfixOps -import akka.stream.Materializer -import play.api.libs.json.{ JsNull, JsObject, JsString, JsValue, JsArray } +import akka.stream.{ IOResult, Materializer } +import play.api.libs.json.{ JsArray, JsNull, JsObject, JsString, JsValue } import play.api.libs.json.JsLookupResult.jsLookupResultToJsLookup import play.api.libs.json.JsValue.jsValueToJsLookup import play.api.libs.json.Json import play.api.libs.json.Json.toJsFieldJsValueWrapper -import org.elastic4play.BadRequestError +import org.elastic4play.{ BadRequestError, InternalError } import org.elastic4play.models.{ AttributeDef, BaseEntity, ChildModelDef, EntityDef, HiveEnumeration, AttributeFormat ⇒ F, AttributeOption ⇒ O } -import org.elastic4play.services.{ Attachment, DBLists } +import org.elastic4play.services.{ Attachment, AttachmentSrv, DBLists } import org.elastic4play.utils.MultiHash import models.JsonFormat.artifactStatusFormat +import play.api.Logger import services.{ ArtifactSrv, AuditedModel } +import scala.util.Success + object ArtifactStatus extends Enumeration with HiveEnumeration { type Type = Value val Ok, Deleted = Value @@ -28,7 +33,7 @@ trait ArtifactAttributes { _: AttributeDef ⇒ val artifactId: A[String] = attribute("_id", F.stringFmt, "Artifact id", O.model) val data: A[Option[String]] = optionalAttribute("data", F.stringFmt, "Content of the artifact", O.readonly) val dataType: A[String] = attribute("dataType", F.listEnumFmt("artifactDataType")(dblists), "Type of the artifact", O.readonly) - val message: A[String] = attribute("message", F.textFmt, "Description of the artifact in the context of the case") + val message: A[Option[String]] = optionalAttribute("message", F.textFmt, "Description of the artifact in the context of the case") val startDate: A[Date] = attribute("startDate", F.dateFmt, "Creation date", new Date) val attachment: A[Option[Attachment]] = optionalAttribute("attachment", F.attachmentFmt, "Artifact file content", O.readonly) val tlp: A[Long] = attribute("tlp", F.numberFmt, "TLP level", 2L) @@ -42,12 +47,14 @@ trait ArtifactAttributes { _: AttributeDef ⇒ class ArtifactModel @Inject() ( caseModel: CaseModel, val dblists: DBLists, + attachmentSrv: AttachmentSrv, artifactSrv: Provider[ArtifactSrv], implicit val mat: Materializer, implicit val ec: ExecutionContext) extends ChildModelDef[ArtifactModel, Artifact, CaseModel, Case](caseModel, "case_artifact") with ArtifactAttributes with AuditedModel { + private[ArtifactModel] lazy val logger = Logger(getClass) override val removeAttribute: JsObject = Json.obj("status" → ArtifactStatus.Deleted) - override def apply(attributes: JsObject) = { + override def apply(attributes: JsObject): Artifact = { val tags = (attributes \ "tags").asOpt[Seq[JsString]].getOrElse(Nil).distinct new Artifact(this, attributes + ("tags" → JsArray(tags))) } @@ -55,24 +62,53 @@ class ArtifactModel @Inject() ( // this method modify request in order to hash artifact and manager file upload override def creationHook(parent: Option[BaseEntity], attrs: JsObject): Future[JsObject] = { val keys = attrs.keys + if (!keys.contains("message") && (attrs \ "tags").asOpt[Seq[JsValue]].forall(_.isEmpty)) + throw BadRequestError(s"Artifact must contain a message or on ore more tags") if (keys.contains("data") == keys.contains("attachment")) throw BadRequestError(s"Artifact must contain data or attachment (but not both)") - computeId(parent, attrs).map { id ⇒ + computeId(parent.getOrElse(throw InternalError(s"artifact $attrs has no parent")), attrs).map { id ⇒ attrs + ("_id" → JsString(id)) } } - def computeId(parent: Option[BaseEntity], attrs: JsObject): Future[String] = { + override def updateHook(entity: BaseEntity, updateAttrs: JsObject): Future[JsObject] = { + entity match { + case artifact: Artifact ⇒ + val removeMessage = (updateAttrs \ "message").toOption.exists { + case JsNull ⇒ true + case JsArray(Nil) ⇒ true + case _ ⇒ false + } + val removeTags = (updateAttrs \ "tags").toOption.exists { + case JsNull ⇒ true + case JsArray(Nil) ⇒ true + case _ ⇒ false + } + if ((removeMessage && removeTags) || + (removeMessage && artifact.tags().isEmpty) || + (removeTags && artifact.message().isEmpty)) + Future.failed(BadRequestError(s"Artifact must contain a message or on ore more tags")) + else + Future.successful(updateAttrs) + } + } + def computeId(parent: BaseEntity, attrs: JsObject): Future[String] = { // in order to make sure that there is no duplicated artifact, calculate its id from its content (dataType, data, attachment and parent) val mm = new MultiHash("MD5") mm.addValue((attrs \ "data").asOpt[JsValue].getOrElse(JsNull)) mm.addValue((attrs \ "dataType").asOpt[JsValue].getOrElse(JsNull)) - (attrs \ "attachment" \ "filepath").asOpt[String] - .fold(Future.successful(()))(file ⇒ mm.addFile(file)) - .map { _ ⇒ - mm.addValue(JsString(parent.fold("")(_.id))) - mm.digest.toString - } + for { + IOResult(_, done) ← (attrs \ "attachment" \ "filepath").asOpt[String] + .fold(Future.successful(IOResult(0, Success(Done))))(file ⇒ mm.addFile(file)) + _ ← Future.fromTry(done) + _ ← (attrs \ "attachment" \ "id").asOpt[String] + .fold(Future.successful(NotUsed: NotUsed)) { fileId ⇒ + mm.addFile(attachmentSrv.source(fileId)) + } + } yield { + mm.addValue(JsString(parent.id)) + mm.digest.toString + } } override def getStats(entity: BaseEntity): Future[JsObject] = { diff --git a/thehive-backend/app/models/Audit.scala b/thehive-backend/app/models/Audit.scala index 3c5b8810ee..3bb152c059 100644 --- a/thehive-backend/app/models/Audit.scala +++ b/thehive-backend/app/models/Audit.scala @@ -6,7 +6,7 @@ import javax.inject.{ Inject, Singleton } import scala.collection.immutable import play.api.{ Configuration, Logger } import play.api.libs.json.JsObject -import org.elastic4play.models.{ Attribute, AttributeDef, EntityDef, EnumerationAttributeFormat, ModelDef, ObjectAttributeFormat, StringAttributeFormat, AttributeFormat ⇒ F, AttributeOption ⇒ O } +import org.elastic4play.models.{ Attribute, AttributeFormat, AttributeDef, EntityDef, EnumerationAttributeFormat, ListEnumerationAttributeFormat, ModelDef, MultiAttributeFormat, ObjectAttributeFormat, OptionalAttributeFormat, StringAttributeFormat, AttributeOption ⇒ O } import org.elastic4play.services.AuditableAction import org.elastic4play.services.JsonFormat.auditableActionFormat import services.AuditedModel @@ -14,15 +14,15 @@ import services.AuditedModel trait AuditAttributes { _: AttributeDef ⇒ def detailsAttributes: Seq[Attribute[_]] - val operation: A[AuditableAction.Value] = attribute("operation", F.enumFmt(AuditableAction), "Operation", O.readonly) - val details: A[JsObject] = attribute("details", F.objectFmt(detailsAttributes), "Details", JsObject(Nil), O.readonly) - val otherDetails: A[Option[String]] = optionalAttribute("otherDetails", F.textFmt, "Other details", O.readonly) - val objectType: A[String] = attribute("objectType", F.stringFmt, "Table affected by the operation", O.readonly) - val objectId: A[String] = attribute("objectId", F.stringFmt, "Object targeted by the operation", O.readonly) - val base: A[Boolean] = attribute("base", F.booleanFmt, "Indicates if this operation is the first done for a http query", O.readonly) - val startDate: A[Date] = attribute("startDate", F.dateFmt, "Date and time of the operation", new Date, O.readonly) - val rootId: A[String] = attribute("rootId", F.stringFmt, "Root element id (routing id)", O.readonly) - val requestId: A[String] = attribute("requestId", F.stringFmt, "Id of the request that do the operation", O.readonly) + val operation: A[AuditableAction.Value] = attribute("operation", AttributeFormat.enumFmt(AuditableAction), "Operation", O.readonly) + val details: A[JsObject] = attribute("details", AttributeFormat.objectFmt(detailsAttributes), "Details", JsObject(Nil), O.readonly) + val otherDetails: A[Option[String]] = optionalAttribute("otherDetails", AttributeFormat.textFmt, "Other details", O.readonly) + val objectType: A[String] = attribute("objectType", AttributeFormat.stringFmt, "Table affected by the operation", O.readonly) + val objectId: A[String] = attribute("objectId", AttributeFormat.stringFmt, "Object targeted by the operation", O.readonly) + val base: A[Boolean] = attribute("base", AttributeFormat.booleanFmt, "Indicates if this operation is the first done for a http query", O.readonly) + val startDate: A[Date] = attribute("startDate", AttributeFormat.dateFmt, "Date and time of the operation", new Date, O.readonly) + val rootId: A[String] = attribute("rootId", AttributeFormat.stringFmt, "Root element id (routing id)", O.readonly) + val requestId: A[String] = attribute("requestId", AttributeFormat.stringFmt, "Id of the request that do the operation", O.readonly) } @Singleton @@ -37,42 +37,61 @@ class AuditModel( configuration.getString("audit.name").get, auditedModels) - lazy val log = Logger(getClass) + lazy val logger = Logger(getClass) - def detailsAttributes: Seq[Attribute[_]] = { - auditedModels - .flatMap(_.attributes) - .flatMap { - // if attribute is object, add sub attributes - case attr @ Attribute(_, _, ObjectAttributeFormat(subAttributes), _, _, _) ⇒ attr +: subAttributes - case attr ⇒ Seq(attr) - } - .filter(_.isModel) + def mergeAttributeFormat(context: String, format1: AttributeFormat[_], format2: AttributeFormat[_]): Option[AttributeFormat[_]] = { + (format1, format2) match { + case (OptionalAttributeFormat(f1), f2) ⇒ mergeAttributeFormat(context, f1, f2) + case (f1, OptionalAttributeFormat(f2)) ⇒ mergeAttributeFormat(context, f1, f2) + case (MultiAttributeFormat(f1), MultiAttributeFormat(f2)) ⇒ mergeAttributeFormat(context, f1, f2).map(MultiAttributeFormat(_)) + case (f1, EnumerationAttributeFormat(_) | ListEnumerationAttributeFormat(_)) ⇒ mergeAttributeFormat(context, f1, StringAttributeFormat) + case (EnumerationAttributeFormat(_) | ListEnumerationAttributeFormat(_), f2) ⇒ mergeAttributeFormat(context, StringAttributeFormat, f2) + case (ObjectAttributeFormat(subAttributes1), ObjectAttributeFormat(subAttributes2)) ⇒ mergeAttributes(context, subAttributes1 ++ subAttributes2) + case (f1, f2) if f1 == f2 ⇒ Some(f1) + case (f1, f2) ⇒ + logger.warn(s"Attribute $f1 != $f2") + None + + } + } + + def mergeAttributes(context: String, attributes: Seq[Attribute[_]]): Option[ObjectAttributeFormat] = { + val mergeAttributes: Iterable[Option[Attribute[_]]] = attributes .groupBy(_.name) - .flatMap { - // if only one attribute is found for a name, get it - case (_, attribute @ Seq(_)) ⇒ attribute - // otherwise, check if attribute format is compatible - case (_, attributes) ⇒ - attributes.headOption.foreach { first ⇒ - val isSensitive = first.isSensitive - val formatName = first.format.name - if (!attributes.forall(a ⇒ a.isSensitive == isSensitive && a.format.name == formatName)) { - log.error("Mapping is not consistent :") - attributes.foreach { attr ⇒ - val s = if (attr.isSensitive) " (is sensitive)" else "" - log.error(s"\t${attr.name} : ${attr.format.name} $s") - } - } - } - attributes.headOption - } .map { - case attr @ Attribute(_, _, EnumerationAttributeFormat(_), _, _, _) ⇒ attr.copy(format = StringAttributeFormat, defaultValue = None) - case attr ⇒ attr + case (_name, _attributes) ⇒ + _attributes + .map(a ⇒ Some(a.format)) + .reduce[Option[AttributeFormat[_]]] { + case (Some(f1), Some(f2)) ⇒ mergeAttributeFormat(context + "." + _name, f1, f2) + case _ ⇒ None + } + .map { + case oaf: OptionalAttributeFormat[_] ⇒ oaf: AttributeFormat[_] + case maf: MultiAttributeFormat[_] ⇒ maf: AttributeFormat[_] + case f ⇒ OptionalAttributeFormat(f): AttributeFormat[_] + } + .map(format ⇒ Attribute("audit", _name, format, Nil, None, "")) + .orElse { + logger.error(s"Mapping is not consistent on attribute $context:\n${_attributes.map(a ⇒ a.modelName + "/" + a.name + ": " + a.format.name).mkString("\n")}") + None + } } - .toSeq + + if (mergeAttributes.exists(_.isEmpty)) + None + else + Some(ObjectAttributeFormat(mergeAttributes.flatten.toSeq)) } + def detailsAttributes: Seq[Attribute[_]] = { + mergeAttributes("audit", auditedModels + .flatMap(_.attributes) + .filter(a ⇒ a.isModel && !a.isUnaudited) + .toSeq) + .map(_.subAttributes) + .getOrElse(Nil) + } + override def apply(attributes: JsObject): Audit = new Audit(this, attributes) } diff --git a/thehive-backend/app/models/Case.scala b/thehive-backend/app/models/Case.scala index a277dce37c..8fe9d83d55 100644 --- a/thehive-backend/app/models/Case.scala +++ b/thehive-backend/app/models/Case.scala @@ -3,18 +3,19 @@ package models import java.util.Date import javax.inject.{ Inject, Provider, Singleton } -import scala.concurrent.{ ExecutionContext, Future } -import scala.math.BigDecimal.{ int2bigDecimal, long2bigDecimal } -import play.api.Logger -import play.api.libs.json._ -import play.api.libs.json.JsValue.jsValueToJsLookup -import play.api.libs.json.Json.toJsFieldJsValueWrapper +import models.JsonFormat.{ caseImpactStatusFormat, caseResolutionStatusFormat, caseStatusFormat } import org.elastic4play.JsonFormat.dateFormat import org.elastic4play.models.{ AttributeDef, BaseEntity, EntityDef, HiveEnumeration, ModelDef, AttributeFormat ⇒ F, AttributeOption ⇒ O } import org.elastic4play.services.{ FindSrv, SequenceSrv } -import models.JsonFormat.{ caseImpactStatusFormat, caseResolutionStatusFormat, caseStatusFormat } +import play.api.Logger +import play.api.libs.json.JsValue.jsValueToJsLookup +import play.api.libs.json.Json.toJsFieldJsValueWrapper +import play.api.libs.json._ import services.{ AuditedModel, CaseSrv } +import scala.concurrent.{ ExecutionContext, Future } +import scala.math.BigDecimal.{ int2bigDecimal, long2bigDecimal } + object CaseStatus extends Enumeration with HiveEnumeration { type Type = Value val Open, Resolved, Deleted = Value @@ -42,12 +43,13 @@ trait CaseAttributes { _: AttributeDef ⇒ val flag: A[Boolean] = attribute("flag", F.booleanFmt, "Flag of the case", false) val tlp: A[Long] = attribute("tlp", F.numberFmt, "TLP level", 2L) val status: A[CaseStatus.Value] = attribute("status", F.enumFmt(CaseStatus), "Status of the case", CaseStatus.Open) - val metrics: A[Option[JsValue]] = optionalAttribute("metrics", F.metricsFmt, "List of metrics") + val metrics: A[JsValue] = attribute("metrics", F.metricsFmt, "List of metrics", JsObject(Nil)) val resolutionStatus: A[Option[CaseResolutionStatus.Value]] = optionalAttribute("resolutionStatus", F.enumFmt(CaseResolutionStatus), "Resolution status of the case") val impactStatus: A[Option[CaseImpactStatus.Value]] = optionalAttribute("impactStatus", F.enumFmt(CaseImpactStatus), "Impact status of the case") val summary: A[Option[String]] = optionalAttribute("summary", F.textFmt, "Summary of the case, to be provided when closing a case") val mergeInto: A[Option[String]] = optionalAttribute("mergeInto", F.stringFmt, "Id of the case created by the merge") val mergeFrom: A[Seq[String]] = multiAttribute("mergeFrom", F.stringFmt, "Id of the cases merged") + val customFields: A[JsValue] = attribute("customFields", F.customFields, "Custom fields", JsObject(Nil)) } @Singleton diff --git a/thehive-backend/app/models/CaseTemplate.scala b/thehive-backend/app/models/CaseTemplate.scala index 1f85bcf992..833f58dcd2 100644 --- a/thehive-backend/app/models/CaseTemplate.scala +++ b/thehive-backend/app/models/CaseTemplate.scala @@ -2,7 +2,7 @@ package models import javax.inject.{ Inject, Singleton } -import play.api.libs.json.JsObject +import play.api.libs.json.{ JsObject, JsValue } import org.elastic4play.models.{ Attribute, AttributeDef, EntityDef, HiveEnumeration, ModelDef, AttributeFormat ⇒ F } import models.JsonFormat.caseTemplateStatusFormat @@ -23,12 +23,15 @@ trait CaseTemplateAttributes { _: AttributeDef ⇒ val tlp: A[Option[Long]] = optionalAttribute("tlp", F.numberFmt, "TLP level") val status: A[CaseTemplateStatus.Value] = attribute("status", F.enumFmt(CaseTemplateStatus), "Status of the case", CaseTemplateStatus.Ok) val metricNames: A[Seq[String]] = multiAttribute("metricNames", F.stringFmt, "List of acceptable metric name") + val customFields: A[Option[JsValue]] = optionalAttribute("customFields", F.customFields, "List of acceptable custom fields") val tasks: A[Seq[JsObject]] = multiAttribute("tasks", F.objectFmt(taskAttributes), "List of created tasks") } @Singleton class CaseTemplateModel @Inject() (taskModel: TaskModel) extends ModelDef[CaseTemplateModel, CaseTemplate]("caseTemplate") with CaseTemplateAttributes { - def taskAttributes: Seq[Attribute[_]] = taskModel.attributes + def taskAttributes: Seq[Attribute[_]] = taskModel + .attributes + .filter(_.isForm) } class CaseTemplate(model: CaseTemplateModel, attributes: JsObject) extends EntityDef[CaseTemplateModel, CaseTemplate](model, attributes) with CaseTemplateAttributes { def taskAttributes = Nil diff --git a/thehive-backend/app/models/Migration.scala b/thehive-backend/app/models/Migration.scala index 20af1f73cd..80ec8ae3e5 100644 --- a/thehive-backend/app/models/Migration.scala +++ b/thehive-backend/app/models/Migration.scala @@ -3,14 +3,18 @@ package models import java.util.Date import javax.inject.Inject +import akka.NotUsed import akka.stream.Materializer +import akka.stream.scaladsl.Source import org.elastic4play.models.BaseModelDef +import org.elastic4play.services.JsonFormat.attachmentFormat import org.elastic4play.services._ import org.elastic4play.utils import org.elastic4play.utils.{ Hasher, RichJson } -import play.api.{ Configuration, Logger } import play.api.libs.json.JsValue.jsValueToJsLookup import play.api.libs.json._ +import play.api.{ Configuration, Logger } +import services.AlertSrv import scala.collection.immutable.{ Set ⇒ ISet } import scala.concurrent.{ ExecutionContext, Future } @@ -21,6 +25,9 @@ case class UpdateMispAlertArtifact() extends EventMessage class Migration( mispCaseTemplate: Option[String], + mainHash: String, + extraHashes: Seq[String], + datastoreName: String, models: ISet[BaseModelDef], dblists: DBLists, eventSrv: EventSrv, @@ -33,10 +40,17 @@ class Migration( eventSrv: EventSrv, ec: ExecutionContext, materializer: Materializer) = { - this(configuration.getString("misp.caseTemplate"), models, dblists, eventSrv, ec, materializer) + this( + configuration.getString("misp.caseTemplate"), + configuration.getString("datastore.hash.main").get, + configuration.getStringSeq("datastore.hash.extra").get, + configuration.getString("datastore.name").get, + models, dblists, + eventSrv, ec, materializer) } import org.elastic4play.services.Operation._ + val logger = Logger(getClass) private var requireUpdateMispAlertArtifact = false @@ -126,9 +140,104 @@ class Migration( "follow" → (misp \ "follow").as[JsBoolean]) }, removeEntity("audit")(o ⇒ (o \ "objectType").asOpt[String].contains("alert"))) + case ds @ DatabaseState(9) ⇒ + object Base64 { + def unapply(data: String): Option[Array[Byte]] = Try(java.util.Base64.getDecoder.decode(data)).toOption + } + + // store attachment id and check to prevent document already exists error + var dataIds = Set.empty[String] + def containsOrAdd(id: String) = { + dataIds.synchronized { + if (dataIds.contains(id)) true + else { + dataIds = dataIds + id + false + } + } + } + + val mainHasher = Hasher(mainHash) + val extraHashers = Hasher(mainHash +: extraHashes: _*) + Seq( + // store alert attachment in datastore + Operation((f: String ⇒ Source[JsObject, NotUsed]) ⇒ { + case "alert" ⇒ f("alert").flatMapConcat { alert ⇒ + val artifactsAndData = Future.traverse((alert \ "artifacts").asOpt[List[JsObject]].getOrElse(Nil)) { artifact ⇒ + val isFile = (artifact \ "dataType").asOpt[String].contains("file") + // get MISP attachment + if (!isFile) + Future.successful(artifact → Nil) + else { + (for { + dataStr ← (artifact \ "data").asOpt[String] + dataJson ← Try(Json.parse(dataStr)).toOption + dataObj ← dataJson.asOpt[JsObject] + filename ← (dataObj \ "filename").asOpt[String].map(_.split("|").head) + attributeId ← (dataObj \ "attributeId").asOpt[String] + attributeType ← (dataObj \ "attributeType").asOpt[String] + } yield Future.successful((artifact - "data" + ("remoteAttachment" → Json.obj( + "reference" → attributeId, + "filename" → filename, + "type" → attributeType))) → Nil)) + .orElse { + (artifact \ "data").asOpt[String] + .collect { + // get attachment encoded in data field + case AlertSrv.dataExtractor(filename, contentType, data @ Base64(rawData)) ⇒ + val attachmentId = mainHasher.fromByteArray(rawData).head.toString() + ds.getEntity(datastoreName, s"${attachmentId}_0") + .map(_ ⇒ Nil) + .recover { + case _ if containsOrAdd(attachmentId) ⇒ Nil + case _ ⇒ + Seq(Json.obj( + "_type" → datastoreName, + "_id" → s"${attachmentId}_0", + "data" → data)) + } + .map { dataEntity ⇒ + val attachment = Attachment(filename, extraHashers.fromByteArray(rawData), rawData.length.toLong, contentType, attachmentId) + (artifact - "data" + ("attachment" → Json.toJson(attachment))) → dataEntity + } + } + + } + .getOrElse(Future.successful(artifact → Nil)) + } + } + Source.fromFuture(artifactsAndData) + .mapConcat { ad ⇒ + val updatedAlert = alert + ("artifacts" → JsArray(ad.map(_._1))) + updatedAlert :: ad.flatMap(_._2) + } + } + case other ⇒ f(other) + }), + // Fix alert status + mapAttribute("alert", "status") { + case JsString("Update") ⇒ JsString("Updated") + case JsString("Ignore") ⇒ JsString("Ignored") + case other ⇒ other + }, + // Fix double encode of metrics + mapEntity("dblist") { + case dblist if (dblist \ "dblist").asOpt[String].contains("case_metrics") ⇒ + (dblist \ "value").asOpt[String].map(Json.parse).fold(dblist) { value ⇒ + dblist + ("value" → value) + } + case other ⇒ other + }, + // Add empty metrics and custom fields in cases + mapEntity("case") { caze ⇒ + val metrics = (caze \ "metrics").asOpt[JsObject].getOrElse(JsObject(Nil)) + val customFields = (caze \ "customFields").asOpt[JsObject].getOrElse(JsObject(Nil)) + caze + ("metrics" → metrics) + ("customFields" → customFields) + }) } private val requestCounter = new java.util.concurrent.atomic.AtomicInteger(0) + def getRequestId: String = { utils.Instance.id + ":mig:" + requestCounter.incrementAndGet() } diff --git a/thehive-backend/app/models/package.scala b/thehive-backend/app/models/package.scala index 42bf029cda..f2f10fe052 100644 --- a/thehive-backend/app/models/package.scala +++ b/thehive-backend/app/models/package.scala @@ -1,5 +1,5 @@ package object models { - val modelVersion = 9 + val modelVersion = 10 } \ No newline at end of file diff --git a/thehive-backend/app/services/AlertSrv.scala b/thehive-backend/app/services/AlertSrv.scala index 6aff658e2b..f75ab78607 100644 --- a/thehive-backend/app/services/AlertSrv.scala +++ b/thehive-backend/app/services/AlertSrv.scala @@ -8,16 +8,27 @@ import akka.stream.Materializer import akka.stream.scaladsl.{ Sink, Source } import connectors.ConnectorRouter import models._ +import org.elastic4play.InternalError import org.elastic4play.controllers.{ Fields, FileInputValue } +import org.elastic4play.services.JsonFormat.attachmentFormat import org.elastic4play.services._ -import play.api.{ Configuration, Logger } import play.api.libs.json._ +import play.api.{ Configuration, Logger } +import scala.collection.immutable import scala.concurrent.{ ExecutionContext, Future } import scala.util.{ Failure, Try } trait AlertTransformer { - def createCase(alert: Alert)(implicit authContext: AuthContext): Future[Case] + def createCase(alert: Alert, customCaseTemplate: Option[String])(implicit authContext: AuthContext): Future[Case] + + def mergeWithCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Case] +} + +case class CaseSimilarity(caze: Case, similarIOCCount: Int, iocCount: Int, similarArtifactCount: Int, artifactCount: Int) + +object AlertSrv { + val dataExtractor = "^(.*);(.*);(.*)".r } class AlertSrv( @@ -33,6 +44,7 @@ class AlertSrv( caseTemplateSrv: CaseTemplateSrv, attachmentSrv: AttachmentSrv, connectors: ConnectorRouter, + hashAlg: Seq[String], implicit val ec: ExecutionContext, implicit val mat: Materializer) extends AlertTransformer { @@ -63,13 +75,30 @@ class AlertSrv( caseTemplateSrv, attachmentSrv, connectors, + (configuration.getString("datastore.hash.main").get +: configuration.getStringSeq("datastore.hash.extra").get).distinct, ec, mat) private[AlertSrv] lazy val logger = Logger(getClass) + import AlertSrv._ - def create(fields: Fields)(implicit authContext: AuthContext): Future[Alert] = - createSrv[AlertModel, Alert](alertModel, fields) + def create(fields: Fields)(implicit authContext: AuthContext): Future[Alert] = { + + val artifactsFields = + Future.traverse(fields.getValues("artifacts")) { + case a: JsObject if (a \ "dataType").asOpt[String].contains("file") ⇒ + (a \ "data").asOpt[String] match { + case Some(dataExtractor(filename, contentType, data)) ⇒ + attachmentSrv.save(filename, contentType, java.util.Base64.getDecoder.decode(data)) + .map(attachment ⇒ a - "data" + ("attachment" → Json.toJson(attachment))) + case _ ⇒ Future.successful(a) + } + case a ⇒ Future.successful(a) + } + artifactsFields.flatMap { af ⇒ + createSrv[AlertModel, Alert](alertModel, fields.set("artifacts", JsArray(af))) + } + } def bulkCreate(fieldSet: Seq[Fields])(implicit authContext: AuthContext): Future[Seq[Try[Alert]]] = createSrv[AlertModel, Alert](alertModel, fieldSet) @@ -114,8 +143,9 @@ class AlertSrv( } } - def getCaseTemplate(alert: Alert) = { - val templateName = alert.caseTemplate() + def getCaseTemplate(alert: Alert, customCaseTemplate: Option[String]): Future[Option[CaseTemplate]] = { + val templateName = customCaseTemplate + .orElse(alert.caseTemplate()) .orElse(templates.get(alert.tpe())) .getOrElse(alert.tpe()) caseTemplateSrv.getByName(templateName) @@ -123,18 +153,16 @@ class AlertSrv( .recover { case _ ⇒ None } } - private val dataExtractor = "^(.*);(.*);(.*)".r - - def createCase(alert: Alert)(implicit authContext: AuthContext): Future[Case] = { + def createCase(alert: Alert, customCaseTemplate: Option[String])(implicit authContext: AuthContext): Future[Case] = { alert.caze() match { case Some(id) ⇒ caseSrv.get(id) case None ⇒ connectors.get(alert.tpe()) match { - case Some(connector: AlertTransformer) ⇒ connector.createCase(alert) + case Some(connector: AlertTransformer) ⇒ connector.createCase(alert, customCaseTemplate) case _ ⇒ - getCaseTemplate(alert).flatMap { caseTemplate ⇒ - println(s"Create case using template $caseTemplate") - caseSrv.create( + for { + caseTemplate ← getCaseTemplate(alert, customCaseTemplate) + caze ← caseSrv.create( Fields.empty .set("title", s"#${alert.sourceRef()} " + alert.title()) .set("description", alert.description()) @@ -143,59 +171,63 @@ class AlertSrv( .set("tlp", JsNumber(alert.tlp())) .set("status", CaseStatus.Open.toString), caseTemplate) - .flatMap { caze ⇒ setCase(alert, caze).map(_ ⇒ caze) } - .flatMap { caze ⇒ - val artifactsFields = alert.artifacts() - .map { artifact ⇒ - val tags = (artifact \ "tags").asOpt[Seq[JsString]].getOrElse(Nil) :+ JsString("src:" + alert.tpe()) - val message = (artifact \ "message").asOpt[JsString].getOrElse(JsString("")) - val artifactFields = Fields(artifact + - ("tags" → JsArray(tags)) + - ("message" → message)) - if (artifactFields.getString("dataType").contains("file")) { - artifactFields.getString("data") - .map { - case dataExtractor(filename, contentType, data) ⇒ - val f = Files.createTempFile("alert-", "-attachment") - Files.write(f, java.util.Base64.getDecoder.decode(data)) - artifactFields - .set("attachment", FileInputValue(filename, f, contentType)) - .unset("data") - case data ⇒ - logger.warn(s"Invalid data format for file artifact: $data") - artifactFields - } - .getOrElse(artifactFields) - } - else { - artifactFields - } - } - - val createdCase = artifactSrv.create(caze, artifactsFields) - .map { r ⇒ - r.foreach { - case Failure(e) ⇒ logger.warn("Create artifact error", e) - case _ ⇒ - } - caze - } - createdCase.onComplete { _ ⇒ - // remove temporary files - artifactsFields - .flatMap(_.get("Attachment")) - .foreach { - case FileInputValue(_, file, _) ⇒ Files.delete(file) - case _ ⇒ - } - } - createdCase - } - } + _ ← mergeWithCase(alert, caze) + } yield caze } } } + override def mergeWithCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Case] = { + setCase(alert, caze) + .map { _ ⇒ + val artifactsFields = alert.artifacts() + .map { artifact ⇒ + val tags = (artifact \ "tags").asOpt[Seq[JsString]].getOrElse(Nil) :+ JsString("src:" + alert.tpe()) + val message = (artifact \ "message").asOpt[JsString].getOrElse(JsString("")) + val artifactFields = Fields(artifact + + ("tags" → JsArray(tags)) + + ("message" → message)) + if (artifactFields.getString("dataType").contains("file")) { + artifactFields.getString("data") + .map { + case dataExtractor(filename, contentType, data) ⇒ + val f = Files.createTempFile("alert-", "-attachment") + Files.write(f, java.util.Base64.getDecoder.decode(data)) + artifactFields + .set("attachment", FileInputValue(filename, f, contentType)) + .unset("data") + case data ⇒ + logger.warn(s"Invalid data format for file artifact: $data") + artifactFields + } + .getOrElse(artifactFields) + } + else { + artifactFields + } + } + + artifactSrv.create(caze, artifactsFields) + .map { + _.foreach { + case Failure(e) ⇒ logger.warn("Create artifact error", e) + case _ ⇒ + } + } + .onComplete { _ ⇒ + // remove temporary files + artifactsFields + .flatMap(_.get("Attachment")) + .foreach { + case FileInputValue(_, file, _) ⇒ Files.delete(file) + case _ ⇒ + } + } + caze + } + + } + def setCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Alert] = { updateSrv(alert, Fields(Json.obj("case" → caze.id, "status" → AlertStatus.Imported))) } @@ -213,6 +245,51 @@ class AlertSrv( updateSrv[AlertModel, Alert](alertModel, alertId, Fields(Json.obj("follow" → follow))) } + def similarCases(alert: Alert): Future[Seq[CaseSimilarity]] = { + def similarArtifacts(artifact: JsObject): Option[Source[Artifact, NotUsed]] = { + for { + dataType ← (artifact \ "dataType").asOpt[String] + data ← if (dataType == "file") + (artifact \ "attachment").asOpt[Attachment].map(Right.apply) + else + (artifact \ "data").asOpt[String].map(Left.apply) + } yield artifactSrv.findSimilar(dataType, data, None, Some("all"), Nil)._1 + } + + def getCaseAndArtifactCount(caseId: String): Future[(Case, Int, Int)] = { + import org.elastic4play.services.QueryDSL._ + for { + caze ← caseSrv.get(caseId) + artifactCountJs ← artifactSrv.stats(parent("case", withId(caseId)), Seq(groupByField("ioc", selectCount))) + iocCount = (artifactCountJs \ "1" \ "count").asOpt[Int].getOrElse(0) + artifactCount = (artifactCountJs \\ "count").map(_.as[Int]).sum + } yield (caze, iocCount, artifactCount) + } + + Source(alert.artifacts().to[immutable.Iterable]) + .flatMapConcat { artifact ⇒ + similarArtifacts(artifact) + .getOrElse(Source.empty) + } + .groupBy(100, _.parentId) + .map { + case a if a.ioc() ⇒ (a.parentId, 1, 1) + case a ⇒ (a.parentId, 0, 1) + } + .reduce[(Option[String], Int, Int)] { + case ((caze, iocCount1, artifactCount1), (_, iocCount2, artifactCount2)) ⇒ (caze, iocCount1 + iocCount2, artifactCount1 + artifactCount2) + } + .mergeSubstreams + .mapAsyncUnordered(5) { + case (Some(caseId), similarIOCCount, similarArtifactCount) ⇒ + getCaseAndArtifactCount(caseId).map { + case (caze, iocCount, artifactCount) ⇒ CaseSimilarity(caze, similarIOCCount, iocCount, similarArtifactCount, artifactCount) + } + case _ ⇒ Future.failed(InternalError("Case not found")) + } + .runWith(Sink.seq) + } + def fixStatus()(implicit authContext: AuthContext): Future[Unit] = { import org.elastic4play.services.QueryDSL._ diff --git a/thehive-backend/app/services/ArtifactSrv.scala b/thehive-backend/app/services/ArtifactSrv.scala index 4cd09ebd7e..e37daab702 100644 --- a/thehive-backend/app/services/ArtifactSrv.scala +++ b/thehive-backend/app/services/ArtifactSrv.scala @@ -4,19 +4,17 @@ import javax.inject.{ Inject, Singleton } import akka.NotUsed import akka.stream.scaladsl.Source - -import scala.concurrent.{ ExecutionContext, Future } -import scala.util.{ Failure, Try } -import play.api.libs.json.JsValue.jsValueToJsLookup -import org.elastic4play.{ ConflictError, CreateError } +import models.{ CaseResolutionStatus, CaseStatus, _ } +import org.elastic4play.ConflictError import org.elastic4play.controllers.Fields -import org.elastic4play.services.{ Agg, AuthContext, CreateSrv, DeleteSrv, FieldsSrv, FindSrv, GetSrv, QueryDSL, QueryDef, UpdateSrv } -import models.{ Artifact, ArtifactModel, ArtifactStatus, Case, CaseModel } +import org.elastic4play.services._ import org.elastic4play.utils.{ RichFuture, RichOr } -import models.CaseStatus -import models.CaseResolutionStatus import play.api.Logger import play.api.libs.json.JsObject +import play.api.libs.json.JsValue.jsValueToJsLookup + +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.{ Failure, Try } @Singleton class ArtifactSrv @Inject() ( @@ -38,13 +36,15 @@ class ArtifactSrv @Inject() ( def create(caze: Case, fields: Fields)(implicit authContext: AuthContext): Future[Artifact] = { createSrv[ArtifactModel, Artifact, Case](artifactModel, caze, fields) - .fallbackTo(updateIfDeleted(caze, fields)) // maybe the artifact already exists. If so, search it and update it + .recoverWith { + case error ⇒ updateIfDeleted(caze, fields) // maybe the artifact already exists. If so, search it and update it + } } private def updateIfDeleted(caze: Case, fields: Fields)(implicit authContext: AuthContext): Future[Artifact] = { fieldsSrv.parse(fields, artifactModel).toFuture.flatMap { attrs ⇒ val updatedArtifact = for { - id ← artifactModel.computeId(Some(caze), attrs) + id ← artifactModel.computeId(caze, attrs) artifact ← getSrv[ArtifactModel, Artifact](artifactModel, id) if artifact.status() == ArtifactStatus.Deleted updatedArtifact ← updateSrv[ArtifactModel, Artifact](artifactModel, artifact.id, fields.unset("data").unset("dataType").unset("attachment").set("status", "Ok")) @@ -71,9 +71,8 @@ class ArtifactSrv @Inject() ( case t ⇒ Future.successful(t) } - def get(id: String, fields: Option[Seq[String]] = None)(implicit authContext: AuthContext): Future[Artifact] = { - val fieldAttribute = fields.map { _.flatMap(f ⇒ artifactModel.attributes.find(_.name == f)) } - getSrv[ArtifactModel, Artifact](artifactModel, id, fieldAttribute) + def get(id: String)(implicit authContext: AuthContext): Future[Artifact] = { + getSrv[ArtifactModel, Artifact](artifactModel, id) } def update(id: String, fields: Fields)(implicit authContext: AuthContext): Future[Artifact] = @@ -99,17 +98,33 @@ class ArtifactSrv @Inject() ( } } - def findSimilar(artifact: Artifact, range: Option[String], sortBy: Seq[String]): (Source[Artifact, NotUsed], Future[Long]) = + def findSimilar(artifact: Artifact, range: Option[String], sortBy: Seq[String]): (Source[Artifact, NotUsed], Future[Long]) = { find(similarArtifactFilter(artifact), range, sortBy) + } + + def findSimilar(dataType: String, data: Either[String, Attachment], filter: Option[QueryDef], range: Option[String], sortBy: Seq[String]): (Source[Artifact, NotUsed], Future[Long]) = { + find(similarArtifactFilter(dataType, data, filter.getOrElse(org.elastic4play.services.QueryDSL.any)), range, sortBy) + } private[services] def similarArtifactFilter(artifact: Artifact): QueryDef = { import org.elastic4play.services.QueryDSL._ - val dataType = artifact.dataType() - artifact.data() match { + val data = (artifact.data(), artifact.attachment()) match { + case (Some(_data), None) ⇒ Left(_data) + case (None, Some(attachment)) ⇒ Right(attachment) + case _ ⇒ sys.error("") + } + val filter = parent("case", not(withId(artifact.parentId.get))) + similarArtifactFilter(artifact.dataType(), data, filter) + } + + private[services] def similarArtifactFilter(dataType: String, data: Either[String, Attachment], filter: QueryDef): QueryDef = { + import org.elastic4play.services.QueryDSL._ + data match { // artifact is an hash - case Some(d) if dataType == "hash" ⇒ + case Left(d) if dataType == "hash" ⇒ and( - parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), + filter, + parent("case", and("status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", or( and( @@ -117,18 +132,20 @@ class ArtifactSrv @Inject() ( "dataType" ~= dataType), "attachment.hashes" ~= d)) // artifact contains data but not an hash - case Some(d) ⇒ + case Left(d) ⇒ and( - parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), + filter, + parent("case", and("status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", "data" ~= d, "dataType" ~= dataType) // artifact is a file - case None ⇒ - val hashes = artifact.attachment().toSeq.flatMap(_.hashes).map(_.toString) + case Right(attachment) ⇒ + val hashes = attachment.hashes.map(_.toString) val hashFilter = hashes.map { h ⇒ "attachment.hashes" ~= h } and( - parent("case", and(not(withId(artifact.parentId.get)), "status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), + filter, + parent("case", and("status" ~!= CaseStatus.Deleted, "resolutionStatus" ~!= CaseResolutionStatus.Duplicated)), "status" ~= "Ok", or( hashFilter :+ diff --git a/thehive-backend/app/services/AuditSrv.scala b/thehive-backend/app/services/AuditSrv.scala index beb6d61a00..ad3fb04555 100644 --- a/thehive-backend/app/services/AuditSrv.scala +++ b/thehive-backend/app/services/AuditSrv.scala @@ -23,7 +23,12 @@ trait AuditedModel { self: BaseModelDef ⇒ .toMap def selectAuditedAttributes(attrs: JsObject) = JsObject { attrs.fields.flatMap { - case nv @ (attrName, _) ⇒ auditedAttributes.get(attrName).map(_ ⇒ nv) + case (attrName, value) ⇒ + val attrNames = attrName.split("\\.").toSeq + auditedAttributes.get(attrNames.head).map { _ ⇒ + val reverseNames = attrNames.reverse + reverseNames.drop(1).foldLeft(reverseNames.head → value)((jsTuple, name) ⇒ name → JsObject(Seq(jsTuple))) + } } } } diff --git a/thehive-backend/app/services/CaseMergeSrv.scala b/thehive-backend/app/services/CaseMergeSrv.scala index 50edd8cb0b..6e7fecb009 100644 --- a/thehive-backend/app/services/CaseMergeSrv.scala +++ b/thehive-backend/app/services/CaseMergeSrv.scala @@ -34,6 +34,10 @@ class CaseMergeSrv @Inject() ( import org.elastic4play.services.QueryDSL._ + private[services] def concatOpt[E](entities: Seq[E], sep: String, getId: E ⇒ Long, getStr: E ⇒ Option[String]) = { + JsString(entities.flatMap(e ⇒ getStr(e).map(s ⇒ s"#${getId(e)}:$s")).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)) } @@ -97,8 +101,7 @@ class CaseMergeSrv @Inject() ( private[services] def mergeMetrics(cases: Seq[Case]): JsObject = { val metrics = for { caze ← cases - metrics ← caze.metrics() - metricsObject ← metrics.asOpt[JsObject] + metricsObject ← caze.metrics().asOpt[JsObject] } yield metricsObject val mergedMetrics: Seq[(String, JsValue)] = metrics.flatMap(_.keys).distinct.map { key ⇒ @@ -112,6 +115,23 @@ class CaseMergeSrv @Inject() ( JsObject(mergedMetrics) } + private[services] def mergeCustomFields(cases: Seq[Case]): JsObject = { + val customFields = for { + caze ← cases + customFieldsObject ← caze.customFields().asOpt[JsObject] + } yield customFieldsObject + + val mergedCustomFieldsObject: Seq[(String, JsValue)] = customFields.flatMap(_.keys).distinct.map { key ⇒ + val customFieldsValues = customFields.flatMap(cf ⇒ (cf \ key).asOpt[JsObject]).distinct + if (customFieldsValues.size != 1) + key → JsNull + else + key → customFieldsValues.head + } + + JsObject(mergedCustomFieldsObject) + } + 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] = { @@ -212,7 +232,7 @@ class CaseMergeSrv @Inject() ( } .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("message", concatOpt[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()).distinct.map(JsString))) @@ -249,6 +269,7 @@ class CaseMergeSrv @Inject() ( .set("tlp", JsNumber(cases.map(_.tlp()).max)) .set("status", JsString(CaseStatus.Open.toString)) .set("metrics", mergeMetrics(cases)) + .set("customFields", mergeCustomFields(cases)) .set("resolutionStatus", mergeResolutionStatus(cases)) .set("impactStatus", mergeImpactStatus(cases)) .set("summary", mergeSummary(cases)) diff --git a/thehive-backend/app/services/CaseSrv.scala b/thehive-backend/app/services/CaseSrv.scala index 76302935be..93db6a64e4 100644 --- a/thehive-backend/app/services/CaseSrv.scala +++ b/thehive-backend/app/services/CaseSrv.scala @@ -31,9 +31,16 @@ class CaseSrv @Inject() ( lazy val log = Logger(getClass) def applyTemplate(template: CaseTemplate, originalFields: Fields): Fields = { + def getJsObjectOrEmpty(value: Option[JsValue]) = value.fold(JsObject(Nil)) { + case obj: JsObject ⇒ obj + case _ ⇒ JsObject(Nil) + } + val metricNames = (originalFields.getStrings("metricNames").getOrElse(Nil) ++ template.metricNames()).distinct val metrics = JsObject(metricNames.map(_ → JsNull)) val tags = (originalFields.getStrings("tags").getOrElse(Nil) ++ template.tags()).distinct + val customFields = getJsObjectOrEmpty(template.customFields()) ++ getJsObjectOrEmpty(originalFields.getValue("customFields")) + originalFields .set("title", originalFields.getString("title").map(t ⇒ JsString(template.titlePrefix().getOrElse("") + " " + t))) .set("description", originalFields.getString("description").orElse(template.description()).map(JsString)) @@ -42,6 +49,7 @@ class CaseSrv @Inject() ( .set("flag", originalFields.getBoolean("flag").orElse(template.flag()).map(JsBoolean)) .set("tlp", originalFields.getLong("tlp").orElse(template.tlp()).map(JsNumber(_))) .set("metrics", originalFields.getValue("metrics").flatMap(_.asOpt[JsObject]).getOrElse(JsObject(Nil)) ++ metrics) + .set("customFields", customFields) } def create(fields: Fields, template: Option[CaseTemplate] = None)(implicit authContext: AuthContext): Future[Case] = { @@ -110,7 +118,7 @@ class CaseSrv @Inject() ( "status" ~= "Ok"), Some("all"), Nil) ._1 .flatMapConcat { artifact ⇒ artifactSrv.findSimilar(artifact, Some("all"), Nil)._1 } - .groupBy(20, _.parentId) + .groupBy(100, _.parentId) .map { a ⇒ (a.parentId, Seq(a)) } .reduce((l, r) ⇒ (l._1, r._2 ++ l._2)) .mergeSubstreams diff --git a/thehive-backend/app/services/JsonFormat.scala b/thehive-backend/app/services/JsonFormat.scala new file mode 100644 index 0000000000..8c0d03402c --- /dev/null +++ b/thehive-backend/app/services/JsonFormat.scala @@ -0,0 +1,25 @@ +package services + +import play.api.libs.json.{ Json, OWrites } + +object JsonFormat { + implicit val caseSimilarityWrites: OWrites[CaseSimilarity] = OWrites[CaseSimilarity] { + case CaseSimilarity(caze, similarIocCount, iocCount, similarArtifactCount, artifactCount) ⇒ + Json.obj( + "id" → caze.id, + "_id" → caze.id, + "caseId" → caze.caseId(), + "title" → caze.title(), + "tags" → caze.tags(), + "status" → caze.status(), + "severity" → caze.severity(), + "resolutionStatus" → caze.resolutionStatus(), + "tlp" → caze.tlp(), + "startDate" → caze.startDate(), + "endDate" → caze.endDate(), + "similarIocCount" → similarIocCount, + "iocCount" → iocCount, + "similarArtifactCount" → similarArtifactCount, + "artifactCount" → artifactCount) + } +} diff --git a/thehive-backend/app/services/StreamSrv.scala b/thehive-backend/app/services/StreamSrv.scala index f8047c7430..61706ce738 100644 --- a/thehive-backend/app/services/StreamSrv.scala +++ b/thehive-backend/app/services/StreamSrv.scala @@ -126,6 +126,11 @@ class StreamActor( eventSrv.unsubscribe(self) } + private def normalizeOperation(operation: AuditOperation) = { + operation.entity.model match { + case am: AuditedModel ⇒ operation.copy(details = am.selectAuditedAttributes(operation.details)) + } + } private def receiveWithState(waitingRequest: Option[WaitingRequest], currentMessages: Map[String, Option[StreamMessageGroup[_]]]): Receive = { /* End of HTTP request, mark received messages to ready*/ case Commit(requestId) ⇒ @@ -149,17 +154,18 @@ class StreamActor( /* */ case operation: AuditOperation if operation.entity.model.isInstanceOf[AuditedModel] ⇒ val requestId = operation.authContext.requestId - logger.debug(s"Receiving audit operation : $operation") + val normalizedOperation = normalizeOperation(operation) + logger.debug(s"Receiving audit operation : $operation => $normalizedOperation") val updatedOperationGroup = currentMessages.get(requestId) match { case None ⇒ logger.debug("Operation that comes after the end of request, make operation ready to send") - AuditOperationGroup(auxSrv, operation).makeReady // Operation that comes after the end of request + AuditOperationGroup(auxSrv, normalizedOperation).makeReady // Operation that comes after the end of request case Some(None) ⇒ logger.debug("First operation of the request, creating operation group") - AuditOperationGroup(auxSrv, operation) // First operation related to the given request + AuditOperationGroup(auxSrv, normalizedOperation) // First operation related to the given request case Some(Some(aog: AuditOperationGroup)) ⇒ logger.debug("Operation included in existing group") - aog :+ operation + aog :+ normalizedOperation case _ ⇒ logger.debug("Impossible") sys.error("") diff --git a/thehive-backend/conf/routes b/thehive-backend/conf/routes index 06d2b27400..e7d927db73 100644 --- a/thehive-backend/conf/routes +++ b/thehive-backend/conf/routes @@ -64,6 +64,7 @@ POST /api/alert/:alertId/markAsUnread controllers.AlertCtrl.markAsUn POST /api/alert/:alertId/createCase controllers.AlertCtrl.createCase(alertId) POST /api/alert/:alertId/follow controllers.AlertCtrl.followAlert(alertId) POST /api/alert/:alertId/unfollow controllers.AlertCtrl.unfollowAlert(alertId) +POST /api/alert/:alertId/merge/:caseId controllers.AlertCtrl.mergeWithCase(alertId, caseId) GET /api/flow controllers.FlowCtrl.flow(rootId: Option[String], count: Option[Int]) @@ -75,8 +76,11 @@ POST /api/maintenance/migrate org.elastic4play.controllers.M GET /api/list org.elastic4play.controllers.DBListCtrl.list() DELETE /api/list/:itemId org.elastic4play.controllers.DBListCtrl.deleteItem(itemId) +PATCH /api/list/:itemId org.elastic4play.controllers.DBListCtrl.updateItem(itemId) POST /api/list/:listName org.elastic4play.controllers.DBListCtrl.addItem(listName) GET /api/list/:listName org.elastic4play.controllers.DBListCtrl.listItems(listName) +POST /api/list/:listName/_exists org.elastic4play.controllers.DBListCtrl.itemExists(listName) + GET /api/user/current controllers.UserCtrl.currentUser() POST /api/user/_search controllers.UserCtrl.find() diff --git a/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala b/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala index cb05873255..6310664547 100644 --- a/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala +++ b/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala @@ -22,7 +22,6 @@ import services.{ ArtifactSrv, CustomWSAPI, MergeArtifact } import scala.concurrent.duration.DurationInt import scala.concurrent.{ ExecutionContext, Future } import scala.language.implicitConversions -import scala.util.control.NonFatal import scala.util.{ Failure, Success, Try } object CortexConfig { @@ -199,26 +198,26 @@ class CortexSrv @Inject() ( logger.debug(s"Job $cortexJobId in cortex ${cortex.name} has finished with status $status, updating job $jobId") getSrv[JobModel, Job](jobModel, jobId) .flatMap { job ⇒ - if (status == JobStatus.Success) { - val jobSummary = Try(Json.parse(report)) - .toOption - .flatMap(r ⇒ (r \ "summary").asOpt[JsObject]) - .getOrElse(JsObject(Nil)) - for { - artifact ← artifactSrv.get(job.artifactId()) - reports = Try(Json.parse(artifact.reports()).asOpt[JsObject]).toOption.flatten.getOrElse(JsObject(Nil)) - newReports = reports + (job.analyzerId() → jobSummary) - } artifactSrv.update(job.artifactId(), Fields.empty.set("reports", newReports.toString)) - .onComplete { - case Failure(t) ⇒ logger.warn(s"Unable to insert summary report in artifact", t) - case Success(_) ⇒ - } - } val jobFields = Fields.empty .set("status", status.toString) .set("report", report) .set("endDate", Json.toJson(new Date)) update(job, jobFields) + .andThen { + case _ if status == JobStatus.Success ⇒ + val jobSummary = Try(Json.parse(report)) + .toOption + .flatMap(r ⇒ (r \ "summary").asOpt[JsObject]) + .getOrElse(JsObject(Nil)) + for { + artifact ← artifactSrv.get(job.artifactId()) + reports = Try(Json.parse(artifact.reports()).asOpt[JsObject]).toOption.flatten.getOrElse(JsObject(Nil)) + newReports = reports + (job.analyzerId() → jobSummary) + } artifactSrv.update(job.artifactId(), Fields.empty.set("reports", newReports.toString)) + .recover { + case t ⇒ logger.warn(s"Unable to insert summary report in artifact", t) + } + } } .onComplete { case Failure(e) ⇒ logger.error(s"Update job fails", e) diff --git a/thehive-misp/app/connectors/misp/MispCtrl.scala b/thehive-misp/app/connectors/misp/MispCtrl.scala index d64931b70c..b1f3c9179b 100644 --- a/thehive-misp/app/connectors/misp/MispCtrl.scala +++ b/thehive-misp/app/connectors/misp/MispCtrl.scala @@ -54,7 +54,11 @@ class MispCtrl @Inject() ( Ok("") } - def createCase(alert: Alert)(implicit authContext: AuthContext): Future[Case] = { - mispSrv.createCase(alert) + override def createCase(alert: Alert, customCaseTemplate: Option[String])(implicit authContext: AuthContext): Future[Case] = { + mispSrv.createCase(alert, customCaseTemplate) + } + + override def mergeWithCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Case] = { + mispSrv.mergeWithCase(alert, caze) } } \ No newline at end of file diff --git a/thehive-misp/app/connectors/misp/MispSrv.scala b/thehive-misp/app/connectors/misp/MispSrv.scala index 9e41b52af1..f9b541c3d2 100644 --- a/thehive-misp/app/connectors/misp/MispSrv.scala +++ b/thehive-misp/app/connectors/misp/MispSrv.scala @@ -14,7 +14,7 @@ import net.lingala.zip4j.core.ZipFile import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.model.FileHeader import org.elastic4play.controllers.{ Fields, FileInputValue } -import org.elastic4play.services.{ AttachmentSrv, AuthContext, EventSrv, TempSrv } +import org.elastic4play.services.{ UserSrv ⇒ _, _ } import org.elastic4play.utils.RichJson import org.elastic4play.{ InternalError, NotFoundError } import play.api.inject.ApplicationLifecycle @@ -92,6 +92,7 @@ class MispSrv @Inject() ( attachmentSrv: AttachmentSrv, tempSrv: TempSrv, eventSrv: EventSrv, + migrationSrv: MigrationSrv, httpSrv: CustomWSAPI, environment: Environment, lifecycle: ApplicationLifecycle, @@ -110,19 +111,24 @@ class MispSrv @Inject() ( private[misp] def initScheduler() = { val task = system.scheduler.schedule(0.seconds, mispConfig.interval) { - logger.info("Update of MISP events is starting ...") - userSrv - .inInitAuthContext { implicit authContext ⇒ - synchronize().andThen { case _ ⇒ tempSrv.releaseTemporaryFiles() } - } - .onComplete { - case Success(a) ⇒ - logger.info("Misp synchronization completed") - a.collect { - case Failure(t) ⇒ logger.warn(s"Update MISP error", t) - } - case Failure(t) ⇒ logger.info("Misp synchronization failed", t) - } + if (migrationSrv.isReady) { + logger.info("Update of MISP events is starting ...") + userSrv + .inInitAuthContext { implicit authContext ⇒ + synchronize().andThen { case _ ⇒ tempSrv.releaseTemporaryFiles() } + } + .onComplete { + case Success(a) ⇒ + logger.info("Misp synchronization completed") + a.collect { + case Failure(t) ⇒ logger.warn(s"Update MISP error", t) + } + case Failure(t) ⇒ logger.info("Misp synchronization failed", t) + } + } + else { + logger.info("MISP synchronization cancel, database is not ready") + } } lifecycle.addStopHook { () ⇒ logger.info("Stopping MISP fetching ...") @@ -300,10 +306,10 @@ class MispSrv @Inject() ( "dataType" → "file", "message" → a.comment, "tags" → (artifactTags.value ++ a.tags.map(JsString)), - "data" → Json.obj( + "remoteAttachment" → Json.obj( "filename" → a.value, - "attributeId" → a.id, - "attributeType" → a.tpe).toString, + "reference" → a.id, + "type" → a.tpe), "startDate" → a.date)) case a ⇒ convertAttribute(a).map { j ⇒ val tags = artifactTags ++ (j \ "tags").asOpt[JsArray].getOrElse(JsArray(Nil)) @@ -319,20 +325,20 @@ class MispSrv @Inject() ( attr: JsObject)(implicit authContext: AuthContext): Option[Future[Fields]] = { (for { dataType ← (attr \ "dataType").validate[String] - data ← (attr \ "data").validate[String] + data ← (attr \ "data").validateOpt[String] message ← (attr \ "message").validate[String] startDate ← (attr \ "startDate").validate[Date] - attachment = dataType match { - case "file" ⇒ - val json = Json.parse(data) - for { - attributeId ← (json \ "attributeId").asOpt[String] - attributeType ← (json \ "attributeType").asOpt[String] - fiv = downloadAttachment(mispConnection, attributeId) - } yield if (attributeType == "malware-sample") fiv.map(extractMalwareAttachment) - else fiv - case _ ⇒ None - } + attachmentReference ← (attr \ "remoteAttachment" \ "reference").validateOpt[String] + attachmentType ← (attr \ "remoteAttachment" \ "type").validateOpt[String] + attachment = attachmentReference + .flatMap { + case ref if dataType == "file" ⇒ Some(downloadAttachment(mispConnection, ref)) + case _ ⇒ None + } + .map { + case f if attachmentType.contains("malware-sample") ⇒ f.map(extractMalwareAttachment) + case f ⇒ f + } tags = (attr \ "tags").asOpt[Seq[String]].getOrElse(Nil) tlp = tags.map(_.toLowerCase) .collectFirst { @@ -351,7 +357,8 @@ class MispSrv @Inject() ( .filterNot(_.toLowerCase.startsWith("tlp:")) .map(JsString))) .set("tlp", tlp) - } yield attachment.fold(Future.successful(fields.set("data", data)))(_.map { fiv ⇒ + if attachment.isDefined != data.isDefined + } yield attachment.fold(Future.successful(fields.set("data", data.get)))(_.map { fiv ⇒ fields.set("attachment", fiv) })) match { case JsSuccess(r, _) ⇒ Some(r) @@ -361,21 +368,27 @@ class MispSrv @Inject() ( } } - def createCase(alert: Alert)(implicit authContext: AuthContext): Future[Case] = { + def createCase(alert: Alert, customCaseTemplate: Option[String])(implicit authContext: AuthContext): Future[Case] = { alert.caze() match { case Some(id) ⇒ caseSrv.get(id) case None ⇒ for { - instanceConfig ← getInstanceConfig(alert.source()) - caseTemplate ← alertSrv.getCaseTemplate(alert) + caseTemplate ← alertSrv.getCaseTemplate(alert, customCaseTemplate) caze ← caseSrv.create(Fields(alert.toCaseJson), caseTemplate) - _ ← alertSrv.setCase(alert, caze) - artifacts ← Future.sequence(alert.artifacts().flatMap(attributeToArtifact(instanceConfig, alert, _))) - _ ← artifactSrv.create(caze, artifacts) + _ ← mergeWithCase(alert, caze) } yield caze } } + def mergeWithCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Case] = { + for { + instanceConfig ← getInstanceConfig(alert.source()) + _ ← alertSrv.setCase(alert, caze) + artifacts ← Future.sequence(alert.artifacts().flatMap(attributeToArtifact(instanceConfig, alert, _))) + _ ← artifactSrv.create(caze, artifacts) + } yield caze + } + def updateMispAlertArtifact()(implicit authContext: AuthContext): Future[Unit] = { import org.elastic4play.services.QueryDSL._ logger.info("Update MISP attributes in alerts") diff --git a/ui/.jshintrc b/ui/.jshintrc index 000352f54d..1819bd8783 100644 --- a/ui/.jshintrc +++ b/ui/.jshintrc @@ -23,11 +23,13 @@ "updatableLink": false, "setTimeout": false, "_": false, + "s": false, "CryptoJS": false, "c3": false, "saveSvgAsPng": false, "Blob": false, "File": false, - "hljs": false + "hljs": false, + "Base64": false } } diff --git a/ui/Gruntfile.js b/ui/Gruntfile.js index b7fad85ef4..bc8b3c5d59 100644 --- a/ui/Gruntfile.js +++ b/ui/Gruntfile.js @@ -365,14 +365,20 @@ module.exports = function(grunt) { '*.{ico,png,txt}', '.htaccess', '*.html', - 'images/{,*/}*.{webp}', - 'styles/fonts/{,*/}*.*' + 'images/{,*/}*.{webp}' + //'styles/fonts/{,*/}*.*' ] }, { expand: true, cwd: '.tmp/images', dest: '<%= yeoman.dist %>/images', src: ['generated/*'] + }, { + expand: true, + dot: true, + cwd: '<%= yeoman.app %>/styles', + src: ['fonts/*.*'], + dest: '<%= yeoman.dist %>' }, { expand: true, cwd: 'bower_components/bootstrap/dist', diff --git a/ui/app/index.html b/ui/app/index.html index fcaff4a83a..cf277ef6a5 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -45,6 +45,7 @@ + @@ -87,7 +88,6 @@ - @@ -105,6 +105,9 @@ + + + @@ -132,6 +135,8 @@ + + @@ -173,6 +178,7 @@ + @@ -182,8 +188,10 @@ + + @@ -196,6 +204,7 @@ + @@ -214,6 +223,7 @@ + diff --git a/ui/app/scripts/app.js b/ui/app/scripts/app.js index 6b1db96e36..8dfb0b5132 100644 --- a/ui/app/scripts/app.js +++ b/ui/app/scripts/app.js @@ -6,17 +6,23 @@ angular.module('theHiveDirectives', []); angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstrap', 'ui.router', 'ui.sortable', 'theHiveControllers', 'theHiveServices', 'theHiveFilters', 'theHiveDirectives', 'yaru22.jsonHuman', 'timer', 'angularMoment', 'ngCsv', 'ngTagsInput', 'btford.markdown', - 'ngResource', 'ui-notification', 'angularjs-dropdown-multiselect', 'base64', 'angular-clipboard', - 'LocalStorageModule', 'angular-markdown-editor', 'hc.marked', 'hljs', 'ui.ace', 'angular-page-loader', 'naif.base64', 'images-resizer' + 'ngResource', 'ui-notification', 'angularjs-dropdown-multiselect', 'angular-clipboard', + 'LocalStorageModule', 'angular-markdown-editor', 'hc.marked', 'hljs', 'ui.ace', 'angular-page-loader', 'naif.base64', 'images-resizer', 'duScroll' ]) .config(function($resourceProvider) { 'use strict'; $resourceProvider.defaults.stripTrailingSlashes = true; }) - .config(function($compileProvider) { + .config(function($compileProvider, markedProvider) { 'use strict'; $compileProvider.debugInfoEnabled(false); + + markedProvider.setRenderer({ + link: function(href, title, text) { + return "" + text + ""; + } + }); }) .config(function($stateProvider, $urlRouterProvider) { 'use strict'; @@ -172,6 +178,13 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra controller: 'AdminMetricsCtrl', title: 'Metrics administration' }) + .state('app.administration.custom-fields', { + url: '/custom-fields', + templateUrl: 'views/partials/admin/custom-fields.html', + controller: 'AdminCustomFieldsCtrl', + controllerAs: '$vm', + title: 'Custom fields administration' + }) .state('app.administration.observables', { url: '/observables', templateUrl: 'views/partials/admin/observables.html', diff --git a/ui/app/scripts/controllers/RootCtrl.js b/ui/app/scripts/controllers/RootCtrl.js index ef0d1b6cfd..d311b74aa3 100644 --- a/ui/app/scripts/controllers/RootCtrl.js +++ b/ui/app/scripts/controllers/RootCtrl.js @@ -2,7 +2,7 @@ * Controller for main page */ angular.module('theHiveControllers').controller('RootCtrl', - function($scope, $rootScope, $uibModal, $location, $state, $base64, AuthenticationSrv, AlertingSrv, StreamSrv, StreamStatSrv, TemplateSrv, MetricsCacheSrv, NotificationSrv, AppLayoutSrv, currentUser, appConfig) { + function($scope, $rootScope, $uibModal, $location, $state, AuthenticationSrv, AlertingSrv, StreamSrv, StreamStatSrv, TemplateSrv, CustomFieldsCacheSrv, MetricsCacheSrv, NotificationSrv, AppLayoutSrv, currentUser, appConfig) { 'use strict'; if(currentUser === 520) { @@ -21,6 +21,7 @@ angular.module('theHiveControllers').controller('RootCtrl', data: 'mytasks' }; $scope.mispEnabled = false; + $scope.customFieldsCache = []; StreamSrv.init(); $scope.currentUser = currentUser; @@ -72,6 +73,11 @@ angular.module('theHiveControllers').controller('RootCtrl', }); }); + $scope.$on('custom-fields:refresh', function() { + // Get custom fields cache + $scope.initCustomFieldsCache(); + }); + $scope.$on('alert:event-imported', function() { $scope.alertEvents = AlertingSrv.stats($scope); }); @@ -81,6 +87,13 @@ angular.module('theHiveControllers').controller('RootCtrl', // $scope.mispEnabled = enabled; // }); + $scope.initCustomFieldsCache = function() { + CustomFieldsCacheSrv.all().then(function(list) { + $scope.customFieldsCache = list; + }); + }; + $scope.initCustomFieldsCache(); + $scope.isAdmin = function(user) { var u = user; var re = /admin/i; @@ -122,7 +135,7 @@ angular.module('theHiveControllers').controller('RootCtrl', }; $scope.search = function(querystring) { - var query = $base64.encode(angular.toJson({ + var query = Base64.encode(angular.toJson({ _string: querystring })); diff --git a/ui/app/scripts/controllers/SearchCtrl.js b/ui/app/scripts/controllers/SearchCtrl.js index 779d4273c5..3311775216 100644 --- a/ui/app/scripts/controllers/SearchCtrl.js +++ b/ui/app/scripts/controllers/SearchCtrl.js @@ -1,7 +1,7 @@ (function() { 'use strict'; angular.module('theHiveControllers') - .controller('SearchCtrl', function($scope, $stateParams, $base64, PSearchSrv, CaseTaskSrv, NotificationSrv, EntitySrv, UserInfoSrv) { + .controller('SearchCtrl', function($scope, $stateParams, PSearchSrv, CaseTaskSrv, NotificationSrv, EntitySrv, UserInfoSrv) { $scope.filter = { type: { values: [ @@ -42,7 +42,7 @@ $scope.getUserInfo = UserInfoSrv; $scope.searchResults = PSearchSrv(undefined, 'any', { - 'filter': angular.fromJson($base64.decode($stateParams.q)), + 'filter': angular.fromJson(Base64.decode($stateParams.q)), 'baseFilter': {_string: '!_type:audit AND !_type:data AND !_type:user AND !_type:analyzer AND !_type:alert AND !_type:case_artifact_job_log AND !status:Deleted'}, 'nparent': 10, skipStream: true diff --git a/ui/app/scripts/controllers/StatisticsCtrl.js b/ui/app/scripts/controllers/StatisticsCtrl.js index ed3c9f8e99..98fcddb416 100644 --- a/ui/app/scripts/controllers/StatisticsCtrl.js +++ b/ui/app/scripts/controllers/StatisticsCtrl.js @@ -8,7 +8,14 @@ $scope.globalFilters = StatisticSrv.getFilters() || { fromDate: moment().subtract(30, 'd').toDate(), toDate: moment().toDate(), - tags: [] + tags: [], + tagsAggregator: 'any' + }; + + $scope.tagsAggregators = { + any: 'Any of', + all: 'All of', + none: 'None of' }; $scope.caseByTlp = { @@ -150,6 +157,10 @@ } }; + $scope.setTagsAggregator = function(aggregator) { + $scope.globalFilters.tagsAggregator = aggregator; + }; + // Prepare the global query $scope.prepareGlobalQuery = function() { @@ -157,28 +168,50 @@ var start = $scope.globalFilters.fromDate ? $scope.globalFilters.fromDate.getTime() : '*'; var end = $scope.globalFilters.toDate ? $scope.globalFilters.toDate.setHours(23,59,59,999) : '*'; - // Handle date queries + // Handle tags query var tags = _.map($scope.globalFilters.tags, function(tag) { return tag.text; }); return function(options) { - return { + var queryCriteria = { _and: [ { _between: { _field: options.dateField, _from: start, _to: end} - }, - { - _or: _.map(tags, function(t) { - return { _field: options.tagsField, _value: t }; - }) } ] }; - }; - - //return segments.join(' AND '); + // Adding tags criteria + if(tags.length > 0) { + var tagsCriterions = _.map(tags, function(t) { + return { _field: options.tagsField, _value: t }; + }); + var tagsCriteria = {}; + switch($scope.globalFilters.tagsAggregator) { + case 'all': + tagsCriteria = { + _and: tagsCriterions + }; + break; + case 'none': + tagsCriteria = { + _not: { + _or: tagsCriterions + } + }; + break; + case 'any': + default: + tagsCriteria = { + _or: tagsCriterions + } + } + queryCriteria._and.push(tagsCriteria); + } + + return queryCriteria; + }; }; $scope.filter = function() { diff --git a/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js b/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js index cae892a826..4d85ae349a 100644 --- a/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js @@ -2,13 +2,34 @@ 'use strict'; angular.module('theHiveControllers').controller('AdminCaseTemplatesCtrl', - function($scope, $uibModal, TemplateSrv, NotificationSrv, UtilsSrv, ListSrv, MetricsCacheSrv) { + function($scope, $uibModal, TemplateSrv, NotificationSrv, UtilsSrv, ListSrv, MetricsCacheSrv, CustomFieldsCacheSrv) { $scope.task = ''; $scope.tags = []; $scope.templates = []; $scope.metrics = []; + $scope.fields = []; + $scope.templateCustomFields = []; $scope.templateIndex = -1; + /** + * Convert the template custom fields definition to a list of ordered field names + * to be used for drag&drop sorting feature + */ + var getTemplateCustomFields = function(customFields) { + var result = []; + + result = _.pluck(_.sortBy(_.map(customFields, function(definition, name){ + return { + name: name, + order: definition.order + } + }), function(item){ + return item.order; + }), 'name'); + + return result; + } + $scope.sortableOptions = { handle: '.drag-handle', stop: function(/*e, ui*/) { @@ -17,12 +38,28 @@ axis: 'y' }; - $scope.getMetrics = function() { + $scope.sortableFields = { + handle: '.drag-handle', + axis: 'y' + }; + + $scope.keys = function(obj) { + if(!obj) { + return []; + } + return _.keys(obj); + }; + + $scope.loadCache = function() { MetricsCacheSrv.all().then(function(metrics){ $scope.metrics = metrics; }); + + CustomFieldsCacheSrv.all().then(function(fields){ + $scope.fields = fields; + }); }; - $scope.getMetrics(); + $scope.loadCache(); $scope.getList = function(index) { TemplateSrv.query(function(templates) { @@ -49,6 +86,8 @@ $scope.template = template; $scope.tags = UtilsSrv.objectify($scope.template.tags, 'text'); + + $scope.templateCustomFields = getTemplateCustomFields(template.customFields); }); $scope.templateIndex = index; @@ -63,10 +102,12 @@ tags: [], tasks: [], metricNames: [], + customFields: {}, description: '' }; $scope.tags = []; $scope.templateIndex = -1; + $scope.templateCustomFields = []; }; $scope.reorderTasks = function() { @@ -122,6 +163,18 @@ $scope.template.metricNames = _.without($scope.template.metricNames, metricName); }; + $scope.addCustomField = function(field) { + if($scope.templateCustomFields.indexOf(field.reference) === -1) { + $scope.templateCustomFields.push(field.reference); + } else { + NotificationSrv.log('The custom field [' + field.name + '] has already been added to the template', 'warning'); + } + }; + + $scope.removeCustomField = function(fieldName) { + $scope.templateCustomFields = _.without($scope.templateCustomFields, fieldName); + }; + $scope.deleteTemplate = function() { $uibModal.open({ scope: $scope, @@ -132,7 +185,19 @@ }; $scope.saveTemplate = function() { + // Set tags $scope.template.tags = _.pluck($scope.tags, 'text'); + + // Set custom fields + $scope.template.customFields = {}; + _.each($scope.templateCustomFields, function(value, index) { + var fieldDef = $scope.fields[value]; + + $scope.template.customFields[value] = {}; + $scope.template.customFields[value][fieldDef.type] = null; + $scope.template.customFields[value].order = index + 1; + }); + if (_.isEmpty($scope.template.id)) { $scope.createTemplate(); } else { @@ -178,10 +243,10 @@ $scope.addTask = function() { if(action === 'Add') { if($scope.template.tasks) { - $scope.template.tasks.push(task); + $scope.template.tasks.push(task); } else { $scope.template.tasks = [task]; - } + } } $uibModalInstance.dismiss(); diff --git a/ui/app/scripts/controllers/admin/AdminCustomFieldDialogCtrl.js b/ui/app/scripts/controllers/admin/AdminCustomFieldDialogCtrl.js new file mode 100644 index 0000000000..da62c0dfe1 --- /dev/null +++ b/ui/app/scripts/controllers/admin/AdminCustomFieldDialogCtrl.js @@ -0,0 +1,101 @@ +(function() { + 'use strict'; + + angular.module('theHiveControllers').controller('AdminCustomFieldDialogCtrl', function($scope, $uibModalInstance, ListSrv, NotificationSrv, customField) { + var self = this; + self.config = { + types: [ + 'string', 'number', 'boolean', 'date' + ], + referencePattern: '^[a-zA-Z]{1}[a-zA-Z0-9_-]*' + }; + + self.customField = customField; + self.customField.options = (customField.options || []).join('\n'); + + var onSuccess = function(data) { + $uibModalInstance.close(data); + }; + + var onFailure = function(response) { + NotificationSrv.error('AdminCustomFieldDialogCtrl', response.data, response.status); + }; + + var buildOptionsCollection = function(options) { + if (!options || options === '') { + return []; + } + + var type = self.customField.type; + var values = self.customField.options.split('\n'); + + if (type === 'number') { + return _.without(values.map(function(item) { + return Number(item); + }), NaN); + } + + return values; + }; + + self.saveField = function(form) { + if (!form.$valid) { + return; + } + + var postData = _.pick(self.customField, 'name', 'reference', 'description', 'type'); + postData.options = buildOptionsCollection(self.customField.options); + + if (self.customField.id) { + ListSrv.update({ + 'itemId': self.customField.id + }, { + 'value': postData + }, onSuccess, onFailure); + } else { + + ListSrv.exists({ + 'listId': 'custom_fields' + }, { + key: 'reference', + value: postData.reference + }, function(response) { + + if (response.toJSON().found === true) { + form.reference.$setValidity('unique', false); + form.reference.$setDirty(); + } else { + ListSrv.save({ + 'listId': 'custom_fields' + }, { + 'value': postData + }, onSuccess, onFailure); + } + }, onFailure); + } + }; + + self.clearUniqueReferenceError = function(form) { + form.reference.$setValidity('unique', true); + form.reference.$setPristine(); + } + + self.cancel = function() { + $uibModalInstance.dismiss(); + } + + self.onNamechanged = function(form) { + if (!self.customField.name) { + return; + } + + var reference = s.trim(s.classify(self.customField.name)); + reference = reference.charAt(0).toLowerCase() + reference.slice(1); + + self.customField.reference = reference; + + self.clearUniqueReferenceError(form); + }; + + }); +})(); diff --git a/ui/app/scripts/controllers/admin/AdminCustomFieldsCtrl.js b/ui/app/scripts/controllers/admin/AdminCustomFieldsCtrl.js new file mode 100644 index 0000000000..fa9bd5b9c5 --- /dev/null +++ b/ui/app/scripts/controllers/admin/AdminCustomFieldsCtrl.js @@ -0,0 +1,57 @@ +(function() { + 'use strict'; + + angular.module('theHiveControllers').controller('AdminCustomFieldsCtrl', + function($scope, $uibModal, ListSrv, CustomFieldsCacheSrv, NotificationSrv) { + var self = this; + + self.reference = { + types: ['string', 'number', 'boolean', 'date'] + }; + + self.customFields = []; + + self.initCustomfields = function() { + self.formData = { + name: null, + label: null, + description: null, + type: null, + options: [] + }; + + ListSrv.query({ + 'listId': 'custom_fields' + }, {}, function(response) { + + self.customFields = _.map(response.toJSON(), function(value, key) { + value.id = key; + return value; + }); + + }, function(response) { + NotificationSrv.error('AdminCustomfieldsCtrl', response.data, response.status); + }); + }; + + self.showFieldDialog = function(customField) { + var modalInstance = $uibModal.open({ + templateUrl: 'views/partials/admin/custom-field-dialog.html', + controller: 'AdminCustomFieldDialogCtrl', + controllerAs: '$vm', + size: 'lg', + resolve: { + customField: angular.copy(customField) || {} + } + }); + + modalInstance.result.then(function(data) { + self.initCustomfields(); + CustomFieldsCacheSrv.clearCache(); + $scope.$emit('custom-fields:refresh'); + }); + } + + self.initCustomfields(); + }); +})(); diff --git a/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js b/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js index 3cf76a571e..54a9ac8890 100644 --- a/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminMetricsCtrl.js @@ -15,9 +15,9 @@ ListSrv.query({ 'listId': 'case_metrics' }, {}, function(response) { - - $scope.metrics = _.values(response).filter(_.isString).map(function(item) { - return JSON.parse(item); + $scope.metrics = _.map(response.toJSON(), function(value, metricId) { + value.id = metricId; + return value; }); }, function(response) { @@ -30,7 +30,7 @@ ListSrv.save({ 'listId': 'case_metrics' }, { - 'value': JSON.stringify($scope.metric) + 'value': $scope.metric }, function() { $scope.initMetrics(); diff --git a/ui/app/scripts/controllers/alert/AlertEventCtrl.js b/ui/app/scripts/controllers/alert/AlertEventCtrl.js index 9f70567586..3576ea17b3 100644 --- a/ui/app/scripts/controllers/alert/AlertEventCtrl.js +++ b/ui/app/scripts/controllers/alert/AlertEventCtrl.js @@ -1,10 +1,12 @@ (function() { 'use strict'; angular.module('theHiveControllers') - .controller('AlertEventCtrl', function($scope, $rootScope, $state, $uibModalInstance, AlertingSrv, NotificationSrv, event) { + .controller('AlertEventCtrl', function($scope, $rootScope, $state, $uibModalInstance, CaseResolutionStatus, AlertingSrv, NotificationSrv, event, templates) { var self = this; var eventId = event.id; + self.templates = _.pluck(templates, 'name'); + self.CaseResolutionStatus = CaseResolutionStatus; self.event = event; self.loading = true; @@ -17,6 +19,11 @@ }; self.filteredArtifacts = []; + self.similarityFilters = {}; + self.similaritySorts = ['-startDate', '-similarArtifactCount', '-similarIocCount', '-iocCount']; + self.currentSimilarFilter = ''; + self.similarCasesStats = []; + this.filterArtifacts = function(value) { self.pagination.currentPage = 1; this.pagination.filter= value; @@ -43,6 +50,7 @@ AlertingSrv.get(eventId).then(function(response) { self.event = response.data; self.loading = false; + self.initSimilarCasesFilter(self.event.similarCases); self.dataTypes = _.countBy(self.event.artifacts, function(attr) { return attr.dataType; @@ -58,7 +66,9 @@ self.import = function() { self.loading = true; - AlertingSrv.create(self.event.id).then(function(response) { + AlertingSrv.create(self.event.id, { + caseTemplate: self.event.caseTemplate + }).then(function(response) { $uibModalInstance.dismiss(); $rootScope.$broadcast('alert:event-imported'); @@ -72,6 +82,23 @@ }); }; + self.mergeIntoCase = function(caseId) { + self.loading = true; + AlertingSrv.mergeInto(self.event.id, caseId) + .then(function(response) { + $uibModalInstance.dismiss(); + + $rootScope.$broadcast('alert:event-imported'); + + $state.go('app.case.details', { + caseId: response.data.id + }); + }, function(response) { + self.loading = false; + NotificationSrv.error('AlertEventCtrl', response.data, response.status); + }); + }; + this.follow = function() { var fn = angular.noop; @@ -111,6 +138,50 @@ $uibModalInstance.dismiss(); }; + self.initSimilarCasesFilter = function(data) { + var stats = { + 'Open': 0 + }; + + // Init the stats object + _.each(_.without(_.keys(CaseResolutionStatus), 'Duplicated'), function(key) { + stats[key] = 0 + }); + + _.each(data, function(item) { + if(item.status === 'Open') { + stats[item.status] = stats[item.status] + 1; + } else { + stats[item.resolutionStatus] = stats[item.resolutionStatus] + 1; + } + }); + + var result = []; + _.each(_.keys(stats), function(key) { + result.push({ + key: key, + value: stats[key] + }) + }); + + self.similarCasesStats = result; + }; + + self.filterSimilarCases = function(filter) { + self.currentSimilarFilter = filter; + if(filter === '') { + self.similarityFilters = {}; + } else if(filter === 'Open') { + self.similarityFilters = { + status: filter + }; + } else { + self.similarityFilters = { + resolutionStatus: filter + }; + } + }; + self.load(); }); })(); diff --git a/ui/app/scripts/controllers/alert/AlertListCtrl.js b/ui/app/scripts/controllers/alert/AlertListCtrl.js index dc8e06ba9c..bb047aa2fa 100644 --- a/ui/app/scripts/controllers/alert/AlertListCtrl.js +++ b/ui/app/scripts/controllers/alert/AlertListCtrl.js @@ -1,7 +1,7 @@ (function() { 'use strict'; angular.module('theHiveControllers') - .controller('AlertListCtrl', function($scope, $q, $state, $uibModal, AlertingSrv, NotificationSrv, FilteringSrv, Severity) { + .controller('AlertListCtrl', function($scope, $q, $state, $uibModal, TemplateSrv, AlertingSrv, NotificationSrv, FilteringSrv, Severity) { var self = this; self.list = []; @@ -188,9 +188,12 @@ templateUrl: 'views/partials/alert/event.dialog.html', controller: 'AlertEventCtrl', controllerAs: 'dialog', - size: 'lg', + size: 'max', resolve: { - event: event + event: event, + templates: function() { + return TemplateSrv.query().$promise; + } } }); }; diff --git a/ui/app/scripts/controllers/case/CaseDetailsCtrl.js b/ui/app/scripts/controllers/case/CaseDetailsCtrl.js index 55e5b06e69..9141d6e151 100644 --- a/ui/app/scripts/controllers/case/CaseDetailsCtrl.js +++ b/ui/app/scripts/controllers/case/CaseDetailsCtrl.js @@ -1,21 +1,21 @@ (function() { 'use strict'; - angular.module('theHiveControllers').controller('CaseDetailsCtrl', - function($scope, $state, $uibModal, CaseTabsSrv, UserInfoSrv, PSearchSrv) { + angular.module('theHiveControllers').controller('CaseDetailsCtrl', function($scope, $state, $uibModal, CaseTabsSrv, UserInfoSrv, PSearchSrv) { - CaseTabsSrv.activateTab($state.current.data.tab); + CaseTabsSrv.activateTab($state.current.data.tab); - $scope.isDefined = false; - $scope.state = { - 'editing': false, - 'isCollapsed': true - }; + $scope.isDefined = false; + $scope.state = { + 'editing': false, + 'isCollapsed': true + }; - $scope.attachments = PSearchSrv($scope.caseId, 'case_task_log', { - scope: $scope, - filter: { - '_and': [{ + $scope.attachments = PSearchSrv($scope.caseId, 'case_task_log', { + scope: $scope, + filter: { + '_and': [ + { '_not': { 'status': 'Deleted' } @@ -33,57 +33,115 @@ } } } - }] - }, - pageSize: 100, - nparent: 1 - }); - - $scope.hasNoMetrics = function(caze) { - return !caze.metrics || _.keys(caze.metrics).length === 0 || caze.metrics.length === 0; - }; - - $scope.addMetric = function(metric) { - var modalInstance = $uibModal.open({ - scope: $scope, - templateUrl: 'views/partials/case/case.add.metric.html', - controller: 'CaseAddMetricConfirmCtrl', - size: '', - resolve: { - metric: function() { - return metric; - } } - }); + ] + }, + pageSize: 100, + nparent: 1 + }); - modalInstance.result.then(function() { - if (!$scope.caze.metrics) { - $scope.caze.metrics = {}; + $scope.hasNoMetrics = function(caze) { + return !caze.metrics || _.keys(caze.metrics).length === 0 || caze.metrics.length === 0; + }; + + $scope.addMetric = function(metric) { + var modalInstance = $uibModal.open({ + scope: $scope, + templateUrl: 'views/partials/case/case.add.metric.html', + controller: 'CaseAddMetadataConfirmCtrl', + size: '', + resolve: { + data: function() { + return metric; } - $scope.caze.metrics[metric.name] = null; - $scope.updateField('metrics', $scope.caze.metrics); - $scope.updateMetricsList(); - }); - }; - - $scope.openAttachment = function(attachment) { - $state.go('app.case.tasks-item', { - caseId: $scope.caze.id, - itemId: attachment.case_task.id - }); - }; + } + }); + + modalInstance.result.then(function() { + if (!$scope.caze.metrics) { + $scope.caze.metrics = {}; + } + $scope.caze.metrics[metric.name] = null; + $scope.updateField('metrics', $scope.caze.metrics); + $scope.updateMetricsList(); + }); + }; + + $scope.openAttachment = function(attachment) { + $state.go('app.case.tasks-item', { + caseId: $scope.caze.id, + itemId: attachment.case_task.id + }); + }; + }); + + angular.module('theHiveControllers').controller('CaseCustomFieldsCtrl', function($scope, $uibModal, CustomFieldsCacheSrv) { + var getTemplateCustomFields = function(customFields) { + var result = []; + + result = _.pluck(_.sortBy(_.map(customFields, function(definition, name){ + return { + name: name, + order: definition.order + } + }), function(item){ + return item.order; + }), 'name'); + + return result; } - ); - angular.module('theHiveControllers').controller('CaseAddMetricConfirmCtrl', function($scope, $uibModalInstance, metric) { - $scope.metric = metric; + $scope.getCustomFieldName = function(fieldDef) { + return 'customFields.' + fieldDef.reference + '.' + fieldDef.type; + }; + + $scope.addCustomField = function(customField) { + var modalInstance = $uibModal.open({ + scope: $scope, + templateUrl: 'views/partials/case/case.add.field.html', + controller: 'CaseAddMetadataConfirmCtrl', + size: '', + resolve: { + data: function() { + return customField; + } + } + }); + + modalInstance.result.then(function() { + var temp = $scope.caze.customFields || {}; + + var customFieldValue = {}; + customFieldValue[customField.type] = null; + customFieldValue.order = _.keys(temp).length + 1; + + $scope.updateField('customFields.' + customField.reference, customFieldValue); + $scope.updateCustomFieldsList(); + + $scope.caze.customFields[customField.reference] = customFieldValue; + }); + }; + + $scope.updateCustomFieldsList = function() { + CustomFieldsCacheSrv.all().then(function(fields) { + $scope.orderedFields = getTemplateCustomFields($scope.caze.customFields); + $scope.allCustomFields = _.omit(fields, _.keys($scope.caze.customFields)); + $scope.customFieldsAvailable = _.keys($scope.allCustomFields).length > 0; + }); + }; + + $scope.updateCustomFieldsList(); + }); + + angular.module('theHiveControllers').controller('CaseAddMetadataConfirmCtrl', function($scope, $uibModalInstance, data) { + $scope.data = data; $scope.cancel = function() { - $uibModalInstance.dismiss(metric); + $uibModalInstance.dismiss(data); }; $scope.confirm = function() { - $uibModalInstance.close(metric); + $uibModalInstance.close(data); }; }); diff --git a/ui/app/scripts/controllers/case/CaseLinksCtrl.js b/ui/app/scripts/controllers/case/CaseLinksCtrl.js index 65c27c5382..e551918974 100644 --- a/ui/app/scripts/controllers/case/CaseLinksCtrl.js +++ b/ui/app/scripts/controllers/case/CaseLinksCtrl.js @@ -1,15 +1,17 @@ (function() { 'use strict'; angular.module('theHiveControllers').controller('CaseLinksCtrl', - function($scope, $state, $stateParams, $uibModal, $timeout, CaseTabsSrv) { + function($scope, $state, $stateParams, $uibModal, $timeout, CaseTabsSrv, CaseResolutionStatus) { $scope.caseId = $stateParams.caseId; + $scope.linkStats = []; + $scope.currentFilter = ''; + $scope.filtering = {} var tabName = 'links-' + $scope.caseId; - // Add tab CaseTabsSrv.addTab(tabName, { name: tabName, - label: 'Links', + label: 'Related Cases', closable: true, state: 'app.case.links', params: {} @@ -19,6 +21,54 @@ $timeout(function() { CaseTabsSrv.activateTab(tabName); }, 0); + + $scope.initStats = function(data) { + var stats = { + 'Open': 0 + }; + + // Init the stats object + _.each(_.without(_.keys(CaseResolutionStatus), 'Duplicated'), function(key) { + stats[key] = 0 + }); + + _.each(data, function(item) { + if(item.status === 'Open') { + stats[item.status] = stats[item.status] + 1; + } else { + stats[item.resolutionStatus] = stats[item.resolutionStatus] + 1; + } + }); + + var result = []; + _.each(_.keys(stats), function(key) { + result.push({ + key: key, + value: stats[key] + }) + }); + + return result; + }; + + $scope.filterLinks = function(filter) { + $scope.currentFilter = filter; + if(filter === '') { + $scope.filtering = {}; + } else if(filter === 'Open') { + $scope.filtering = { + status: filter + }; + } else { + $scope.filtering = { + resolutionStatus: filter + }; + } + }; + + $scope.$watch('links', function(data){ + $scope.linkStats = $scope.initStats(data); + }); } ); })(); diff --git a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js index 40f312bc46..c629ec25c3 100644 --- a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js +++ b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js @@ -1,7 +1,7 @@ (function () { 'use strict'; angular.module('theHiveControllers').controller('CaseObservablesCtrl', - function ($scope, $q, $state, $stateParams, $uibModal, CaseTabsSrv, PSearchSrv, CaseArtifactSrv, NotificationSrv, AnalyzerSrv, CortexSrv, ObservablesUISrv, VersionSrv, Tlp) { + function ($scope, $q, $state, $stateParams, $uibModal, StreamSrv, CaseTabsSrv, PSearchSrv, CaseArtifactSrv, NotificationSrv, AnalyzerSrv, CortexSrv, ObservablesUISrv, VersionSrv, Tlp) { CaseTabsSrv.activateTab($state.current.data.tab); @@ -42,6 +42,29 @@ nstats: true }); + // Add a listener to refresh observables list on job finish + StreamSrv.addListener({ + scope: $scope, + rootId: $scope.caseId, + objectType: 'case_artifact_job', + callback: function(data) { + var successFound = false; + var i = 0; + var ln = data.length; + + while(!successFound && i < ln) { + if(data[i].base.operation === 'Update' && data[i].base.details.status === 'Success') { + successFound = true; + } + i++; + } + + if(successFound) { + $scope.artifacts.update(); + } + } + }); + $scope.$watchCollection('artifacts.pageSize', function (newValue) { $scope.uiSrv.setPageSize(newValue); }); @@ -600,7 +623,50 @@ itemId: artifact.id }); }; + + $scope.showReport = function(observable, analyzerId) { + CortexSrv.getJobs($scope.caseId, observable.id, analyzerId, 1) + .then(function(response) { + return CortexSrv.getJob(response.data[0].id) + }) + .then(function(response){ + var job = response.data; + var report = { + job: job, + template: job.analyzerId, + content: job.report, + status: job.status, + startDate: job.startDate, + endDate: job.endDate + }; + + var modalInstance = $uibModal.open({ + templateUrl: 'views/partials/observables/list/job-report-dialog.html', + controller: 'JobReportModalCtrl', + controllerAs: '$vm', + size: 'max', + resolve: { + report: function() { + return report + }, + observable: function() { + return observable; + } + } + }); + }) + .catch(function(err) { + NotificationSrv.error('Unable to fetch the analysis report'); + }) + } + } + ) + .controller('JobReportModalCtrl', function($uibModalInstance, report, observable) { + this.report = report; + this.observable = observable; + this.close = function() { + $uibModalInstance.dismiss(); } - ); + }); })(); diff --git a/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js b/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js index 9bed9f8e13..9b2fbb617d 100644 --- a/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js +++ b/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js @@ -1,7 +1,7 @@ (function () { 'use strict'; angular.module('theHiveControllers').controller('CaseObservablesItemCtrl', - function ($scope, $state, $stateParams, $q, $timeout, CaseTabsSrv, CaseArtifactSrv, CortexSrv, PSearchSrv, AnalyzerSrv, NotificationSrv, VersionSrv, appConfig) { + function ($scope, $state, $stateParams, $q, $timeout, $document, CaseTabsSrv, CaseArtifactSrv, CortexSrv, PSearchSrv, AnalyzerSrv, NotificationSrv, VersionSrv, appConfig) { var observableId = $stateParams.itemId, observableName = 'observable-' + observableId; @@ -78,25 +78,15 @@ $scope.onJobsChange = function (updates) { $scope.analyzerJobs = {}; - angular.forEach($scope.analyzers, function (analyzer, analyzerId) { + _.each(_.keys($scope.analyzers).sort(), function(analyzerId) { $scope.analyzerJobs[analyzerId] = []; - }); + }); angular.forEach($scope.jobs.values, function (job) { if (job.analyzerId in $scope.analyzerJobs) { $scope.analyzerJobs[job.analyzerId].push(job); } else { $scope.analyzerJobs[job.analyzerId] = [job]; - - /* - AnalyzerSrv.get(job.analyzerId) - .finally(function (data) { - $scope.analyzers[data.analyzerId] = { - active: false, - showRows: false - }; - }); - */ } }); @@ -132,6 +122,16 @@ startDate: job.startDate, endDate: job.endDate }; + + $timeout(function() { + var reportEl = angular.element(document.getElementById('analysis-report'))[0]; + + // Scrolling hack using jQuery stuff + $('html,body').animate({ + scrollTop: $(reportEl).offset().top + }, 'fast'); + }, 500); + }, function(/*err*/) { NotificationSrv.log('An expected error occured while fetching the job report'); }); diff --git a/ui/app/scripts/controllers/case/CaseTasksCtrl.js b/ui/app/scripts/controllers/case/CaseTasksCtrl.js index ab833e0781..ac62a7fdf7 100644 --- a/ui/app/scripts/controllers/case/CaseTasksCtrl.js +++ b/ui/app/scripts/controllers/case/CaseTasksCtrl.js @@ -4,7 +4,7 @@ .controller('CaseTaskDeleteCtrl', CaseTaskDeleteCtrl) .controller('CaseTasksCtrl', CaseTasksCtrl); - function CaseTasksCtrl($scope, $state, $stateParams, $uibModal, CaseTabsSrv, PSearchSrv, CaseTaskSrv, UserInfoSrv, NotificationSrv) { + function CaseTasksCtrl($scope, $state, $stateParams, $q, $uibModal, CaseTabsSrv, PSearchSrv, CaseTaskSrv, UserInfoSrv, NotificationSrv) { CaseTabsSrv.activateTab($state.current.data.tab); @@ -83,22 +83,44 @@ }; // open task tab with its details - $scope.openTask = function(task) { + $scope.startTask = function(task) { if (task.status === 'Waiting') { - CaseTaskSrv.update({ - 'taskId': task.id - }, { - 'status': 'InProgress' - }, function(data) { - $scope.showTask(data); - }, function(response) { - NotificationSrv.error('taskList', response.data, response.status); - }); + $scope.updateTaskStatus(task.id, 'InProgress'); } else { $scope.showTask(task); } }; + $scope.openTask = function(task) { + if (task.status === 'Completed') { + $scope.updateTaskStatus(task.id, 'InProgress') + .then($scope.showTask); + } + }; + + $scope.closeTask = function(task) { + if (task.status === 'InProgress') { + $scope.updateTaskStatus(task.id, 'Completed'); + } + }; + + $scope.updateTaskStatus = function(taskId, status) { + var defer = $q.defer(); + + CaseTaskSrv.update({ + 'taskId': taskId + }, { + 'status': status + }, function(data) { + defer.resolve(data); + }, function(response) { + NotificationSrv.error('taskList', response.data, response.status); + defer.reject(response); + }); + + return defer.promise; + }; + } function CaseTaskDeleteCtrl($uibModalInstance, title) { diff --git a/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js b/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js index 4907ba2b6b..5eac4cca07 100644 --- a/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js +++ b/ui/app/scripts/controllers/case/CaseTasksItemCtrl.js @@ -75,7 +75,7 @@ }); }; - $scope.complete = function () { + $scope.closeTask = function () { $scope.task.status = 'Completed'; $scope.updateField('status', 'Completed'); @@ -85,6 +85,11 @@ }); }; + $scope.openTask = function() { + $scope.task.status = 'InProgress'; + $scope.updateField('status', 'InProgress'); + }; + $scope.showLogEditor = function () { $scope.adding = true; $rootScope.$broadcast('beforeNewLogShow'); diff --git a/ui/app/scripts/directives/charts/donut-chart.js b/ui/app/scripts/directives/charts/donut-chart.js index ded97736c4..6499acc58d 100644 --- a/ui/app/scripts/directives/charts/donut-chart.js +++ b/ui/app/scripts/directives/charts/donut-chart.js @@ -1,6 +1,6 @@ (function() { 'use strict'; - angular.module('theHiveDirectives').directive('donutChart', function(StatSrv, $state, $base64, NotificationSrv) { + angular.module('theHiveDirectives').directive('donutChart', function(StatSrv, $state, NotificationSrv) { return { restrict: 'E', scope: { @@ -60,7 +60,7 @@ }; $state.go('app.search', { - q: $base64.encode(angular.toJson(searchQuery)) + q: Base64.encode(angular.toJson(searchQuery)) }); } }, diff --git a/ui/app/scripts/directives/mini-report-list.js b/ui/app/scripts/directives/mini-report-list.js new file mode 100644 index 0000000000..845f02ea44 --- /dev/null +++ b/ui/app/scripts/directives/mini-report-list.js @@ -0,0 +1,32 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives') + .directive('miniReportList', function() { + return { + restrict: 'E', + templateUrl: 'views/directives/mini-report-list.html', + scope: { + observable: '=', + reports: '=', + onItemClicked: '&' + }, + link: function(scope) { + scope.taxonomies = []; + + scope.$watch('reports', function(data) { + var keys = _.keys(data); + var taxonomies = []; + + _.each(keys, function(key) { + taxonomies = taxonomies.concat(_.map(data[key].taxonomies || [], function(item) { + item.id = key; + return item; + })); + }); + + scope.taxonomies = taxonomies; + }); + } + }; + }); +})(); diff --git a/ui/app/scripts/directives/updatableBoolean.js b/ui/app/scripts/directives/updatableBoolean.js new file mode 100644 index 0000000000..e6e231e814 --- /dev/null +++ b/ui/app/scripts/directives/updatableBoolean.js @@ -0,0 +1,19 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives') + .directive('updatableBoolean', function(UtilsSrv) { + return { + 'restrict': 'E', + 'link': UtilsSrv.updatableLink, + 'templateUrl': 'views/directives/updatable-boolean.html', + 'scope': { + 'value': '=?', + 'onUpdate': '&', + 'active': '=?', + 'placeholder': '@', + 'trueText': '@?', + 'falseText': '@?' + } + }; + }); +})(); diff --git a/ui/app/scripts/directives/updatableSelect.js b/ui/app/scripts/directives/updatableSelect.js new file mode 100644 index 0000000000..df9a7d4992 --- /dev/null +++ b/ui/app/scripts/directives/updatableSelect.js @@ -0,0 +1,18 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives') + .directive('updatableSelect', function(UtilsSrv) { + return { + 'restrict': 'E', + 'link': UtilsSrv.updatableLink, + 'templateUrl': 'views/directives/updatable-select.html', + 'scope': { + 'options': '=?', + 'value': '=?', + 'onUpdate': '&', + 'active': '=?', + 'placeholder': '@' + } + }; + }); +})(); diff --git a/ui/app/scripts/filters/percent.js b/ui/app/scripts/filters/percent.js new file mode 100644 index 0000000000..eaf2d6c965 --- /dev/null +++ b/ui/app/scripts/filters/percent.js @@ -0,0 +1,8 @@ +(function() { + 'use strict'; + angular.module('theHiveFilters').filter('percentage', function($filter) { + return function(input, decimals) { + return $filter('number')(input * 100, decimals) + '%'; + }; + }); +})(); diff --git a/ui/app/scripts/services/AlertingSrv.js b/ui/app/scripts/services/AlertingSrv.js index cc28d9fdfd..964561bc4d 100644 --- a/ui/app/scripts/services/AlertingSrv.js +++ b/ui/app/scripts/services/AlertingSrv.js @@ -19,11 +19,19 @@ }, get: function(alertId) { - return $http.get(baseUrl + '/' + alertId); + return $http.get(baseUrl + '/' + alertId, { + params: { + similarity: 1 + } + }); + }, + + create: function(alertId, data) { + return $http.post(baseUrl + '/' + alertId + '/createCase', data || {}); }, - create: function(alertId) { - return $http.post(baseUrl + '/' + alertId + '/createCase', {}); + mergeInto: function(alertId, caseId) { + return $http.post(baseUrl + '/' + alertId + '/merge/' + caseId); }, canMarkAsRead: function(event) { diff --git a/ui/app/scripts/services/CaseTabsSrv.js b/ui/app/scripts/services/CaseTabsSrv.js index 20eb9f9db5..760537831f 100644 --- a/ui/app/scripts/services/CaseTabsSrv.js +++ b/ui/app/scripts/services/CaseTabsSrv.js @@ -6,7 +6,7 @@ 'details': { name: 'details', active: true, - label: 'Summary', + label: 'Details', state: 'app.case.details' }, 'tasks': { diff --git a/ui/app/scripts/services/Constants.js b/ui/app/scripts/services/Constants.js index 3eda94c9cd..0bae8587ac 100644 --- a/ui/app/scripts/services/Constants.js +++ b/ui/app/scripts/services/Constants.js @@ -1,6 +1,7 @@ (function() { 'use strict'; angular.module('theHiveServices') + .value('duScrollOffset', 30) .value('CaseResolutionStatus', { Indeterminate: 'Indeterminate', FalsePositive: 'False Positive', diff --git a/ui/app/scripts/services/CortexSrv.js b/ui/app/scripts/services/CortexSrv.js index 073bf95da0..0dd722c419 100644 --- a/ui/app/scripts/services/CortexSrv.js +++ b/ui/app/scripts/services/CortexSrv.js @@ -1,67 +1,86 @@ -(function () { +(function() { 'use strict'; - angular.module('theHiveServices') - .factory('CortexSrv', function ($q, $http, $rootScope, $uibModal, StatSrv, StreamSrv, AnalyzerSrv, PSearchSrv) { + angular.module('theHiveServices').factory('CortexSrv', function($q, $http, $rootScope, $uibModal, StatSrv, StreamSrv, AnalyzerSrv, PSearchSrv) { - var baseUrl = './api/connector/cortex'; + var baseUrl = './api/connector/cortex'; - var factory = { - list: function (scope, caseId, observableId, callback) { - return PSearchSrv(undefined, 'connector/cortex/job', { - scope: scope, - sort: '-startDate', - loadAll: false, - pageSize: 200, - onUpdate: callback || angular.noop, - streamObjectType: 'case_artifact_job', - filter: { - _parent: { - _type: 'case_artifact', - _query: { - _id: observableId - } + var factory = { + list: function(scope, caseId, observableId, callback) { + return PSearchSrv(undefined, 'connector/cortex/job', { + scope: scope, + sort: '-startDate', + loadAll: false, + pageSize: 200, + onUpdate: callback || angular.noop, + streamObjectType: 'case_artifact_job', + filter: { + _parent: { + _type: 'case_artifact', + _query: { + _id: observableId } } - }); - }, + } + }); + }, + + getJobs: function(caseId, observableId, analyzerId, limit) { + return $http.post(baseUrl + '/job/_search', { + sort: '-startDate', + range: '0-' + (limit || 10), + query: { + _and: [ + { + _parent: { + _type: 'case_artifact', + _query: { + _id: observableId + } + } + }, { + analyzerId: analyzerId + } + ] + } + }) + }, - getJob: function (jobId) { - return $http.get(baseUrl + '/job/' + jobId); - }, + getJob: function(jobId) { + return $http.get(baseUrl + '/job/' + jobId); + }, - createJob: function (job) { - return $http.post(baseUrl + '/job', job); - }, + createJob: function(job) { + return $http.post(baseUrl + '/job', job); + }, - getServers: function (analyzerIds) { - return AnalyzerSrv.serversFor(analyzerIds) - .then(function (servers) { - if (servers.length === 1) { - return $q.resolve(servers[0]); - } else { - return factory.promptForInstance(servers); - } - }); - }, + getServers: function(analyzerIds) { + return AnalyzerSrv.serversFor(analyzerIds).then(function(servers) { + if (servers.length === 1) { + return $q.resolve(servers[0]); + } else { + return factory.promptForInstance(servers); + } + }); + }, - promptForInstance: function (servers) { - var modalInstance = $uibModal.open({ - templateUrl: 'views/partials/cortex/choose-instance-dialog.html', - controller: 'CortexInstanceDialogCtrl', - controllerAs: 'vm', - size: '', - resolve: { - servers: function () { - return servers; - } + promptForInstance: function(servers) { + var modalInstance = $uibModal.open({ + templateUrl: 'views/partials/cortex/choose-instance-dialog.html', + controller: 'CortexInstanceDialogCtrl', + controllerAs: 'vm', + size: '', + resolve: { + servers: function() { + return servers; } - }); + } + }); - return modalInstance.result; - } - }; + return modalInstance.result; + } + }; - return factory; - }); + return factory; + }); })(); diff --git a/ui/app/scripts/services/CustomFieldsCacheSrv.js b/ui/app/scripts/services/CustomFieldsCacheSrv.js new file mode 100644 index 0000000000..72a5f08cbe --- /dev/null +++ b/ui/app/scripts/services/CustomFieldsCacheSrv.js @@ -0,0 +1,49 @@ +(function() { + 'use strict'; + angular.module('theHiveServices').factory('CustomFieldsCacheSrv', function($resource, $q) { + + var cache = null, + resource = $resource('./api/list/:listId', {}, { + query: { + method: 'GET', + isArray: false + }, + add: { + method: 'PUT' + } + }); + + return { + clearCache: function() { + cache = null; + }, + + get: function(name) { + return cache[name]; + }, + + all: function() { + var deferred = $q.defer(); + + if(cache === null) { + resource.query({listId: 'custom_fields'}, {}, function(response) { + var json = response.toJSON(); + cache = {}; + + _.each(_.values(json), function(field) { + cache[field.reference] = field; + }) + + deferred.resolve(cache); + }, function(response) { + deferred.reject(response); + }); + } else { + deferred.resolve(cache); + } + + return deferred.promise; + } + }; + }); +})(); diff --git a/ui/app/scripts/services/ListSrv.js b/ui/app/scripts/services/ListSrv.js index 9479faf890..8bf21707d5 100644 --- a/ui/app/scripts/services/ListSrv.js +++ b/ui/app/scripts/services/ListSrv.js @@ -8,6 +8,14 @@ }, add: { method: 'PUT' + }, + update: { + url: './api/list/:itemId', + method: 'PATCH' + }, + exists: { + url: './api/list/:listId/_exists', + method: 'POST', } }); }); diff --git a/ui/app/scripts/services/MetricsCacheSrv.js b/ui/app/scripts/services/MetricsCacheSrv.js index 0f3c3c4e43..1e41a4146d 100644 --- a/ui/app/scripts/services/MetricsCacheSrv.js +++ b/ui/app/scripts/services/MetricsCacheSrv.js @@ -27,14 +27,13 @@ if(metrics === null) { resource.query({listId: 'case_metrics'}, {}, function(response) { - metrics = {}; - var data = _.values(response).filter(_.isString).map(function(item) { - return JSON.parse(item); - }); + var json = response.toJSON(); - _.each(data, function(m){ - metrics[m.name] = m; - }); + metrics = {}; + + _.each(_.values(json), function(metric) { + metrics[metric.name] = metric; + }) deferred.resolve(metrics); }, function(response) { diff --git a/ui/app/styles/case-item.css b/ui/app/styles/case-item.css new file mode 100644 index 0000000000..eba8ea9552 --- /dev/null +++ b/ui/app/styles/case-item.css @@ -0,0 +1,49 @@ +div.case-collection:nth-of-type(odd) { + background-color: #f9f9f9; +} + +div.case-item { + margin-bottom: 1px; + display: flex; + justify-content: space-around; + align-items: stretch; +} + +div.case-item>div { + padding: 5px; +} + +div.case-item>div.case-tlp { + width: 8px; +} + +div.case-item>div.case-details { + flex: 1; +} + +div.case-item>div.case-owner { + width: 50px; +} + +div.case-item>div.case-severity { + width: 40px; +} + +div.case-item>div.case-date { + width: 120px; +} + +div.case-item>div.case-observables-count { + width: 30px; +} + +div.case-item>div.case-observables-list { + width: 450px; +} +div.case-item>div.case-similarity { + width: 200px; +} +div.case-item>div.case-similarity-merge { + width: 150px; + text-align: center; +} diff --git a/ui/app/styles/case.css b/ui/app/styles/case.css index b109b7b793..d1c99ec68b 100644 --- a/ui/app/styles/case.css +++ b/ui/app/styles/case.css @@ -66,12 +66,14 @@ span.link-id { left: 0 !important; } +.tags-list, table.case-list .case-tags { font-size: 12px !important; } table.case-list .case-tags .label, -.case-tags .label { - font-size: inherit !important; +.case-tags .label, +.tags-list .label { + font-size: 12px !important; font-weight: normal; } diff --git a/ui/app/styles/fonts/SIL Open Font License.txt b/ui/app/styles/fonts/SIL Open Font License.txt new file mode 100755 index 0000000000..295975a5d6 --- /dev/null +++ b/ui/app/styles/fonts/SIL Open Font License.txt @@ -0,0 +1,43 @@ +Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright statement(s). + +"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/ui/app/styles/fonts/SourceSansPro-Black.otf b/ui/app/styles/fonts/SourceSansPro-Black.otf new file mode 100755 index 0000000000..0c25f3d9b7 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-Black.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-BlackIt.otf b/ui/app/styles/fonts/SourceSansPro-BlackIt.otf new file mode 100755 index 0000000000..da3504c68c Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-BlackIt.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-Bold.otf b/ui/app/styles/fonts/SourceSansPro-Bold.otf new file mode 100755 index 0000000000..98dbee74d5 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-Bold.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-BoldIt.otf b/ui/app/styles/fonts/SourceSansPro-BoldIt.otf new file mode 100755 index 0000000000..6600c86319 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-BoldIt.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-ExtraLight.otf b/ui/app/styles/fonts/SourceSansPro-ExtraLight.otf new file mode 100755 index 0000000000..f885ce7f61 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-ExtraLight.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-ExtraLightIt.otf b/ui/app/styles/fonts/SourceSansPro-ExtraLightIt.otf new file mode 100755 index 0000000000..f932024272 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-ExtraLightIt.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-It.otf b/ui/app/styles/fonts/SourceSansPro-It.otf new file mode 100755 index 0000000000..2d627d9cf9 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-It.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-Light.otf b/ui/app/styles/fonts/SourceSansPro-Light.otf new file mode 100755 index 0000000000..159979f600 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-Light.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-LightIt.otf b/ui/app/styles/fonts/SourceSansPro-LightIt.otf new file mode 100755 index 0000000000..e3d49b5fd1 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-LightIt.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-Regular.otf b/ui/app/styles/fonts/SourceSansPro-Regular.otf new file mode 100755 index 0000000000..bdcfb27a4f Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-Regular.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-Semibold.otf b/ui/app/styles/fonts/SourceSansPro-Semibold.otf new file mode 100755 index 0000000000..fffdbafeb7 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-Semibold.otf differ diff --git a/ui/app/styles/fonts/SourceSansPro-SemiboldIt.otf b/ui/app/styles/fonts/SourceSansPro-SemiboldIt.otf new file mode 100755 index 0000000000..e90515b3e1 Binary files /dev/null and b/ui/app/styles/fonts/SourceSansPro-SemiboldIt.otf differ diff --git a/ui/app/styles/main.css b/ui/app/styles/main.css index 73a55bf9a6..564a5fb349 100644 --- a/ui/app/styles/main.css +++ b/ui/app/styles/main.css @@ -54,7 +54,7 @@ body { margin: 0 !important; } -.short-report { +.short-report .label { margin-right: 5px; } .empty-message { @@ -120,6 +120,8 @@ pre.clearpre { .flexwrap{ display: flex; flex-wrap: wrap; + justify-content: flex-start; + align-items: flex-start; } .wrap { word-wrap: break-word; @@ -260,8 +262,9 @@ ul.observable-reports-summary li { padding: 20px 10px 10px 10px; } -.case-metrics dt { - width: 300px !important; +.case-metrics dt, +.case-custom-fields dt { + width: 200px !important; } .scrollable { @@ -397,14 +400,13 @@ ul.observable-reports-summary li { /* IE 9 */ } -tr.task-row td.task-delete { +tr.task-row .task-delete { text-align: center; } -tr.task-row:hover td.task-delete i { - display: block !important; +tr.task-row:hover .task-delete { + display: inline !important; } -tr.task-row td.task-delete i { - cursor: pointer; +tr.task-row .task-delete { display: none; } tr.task-row .tast-status { @@ -495,10 +497,23 @@ tags-input.input-sm .tags.focused { } footer.main-footer { - padding: 2px; + padding: 2px; line-height: 40px; } footer .footer-logo { height: 40px; } + +report:empty { + display: none; +} + +span.action-button { + font-size: 14px; +} + +table tr td.task-actions span.action-button { + width: 60px; + display: inline-block; +} diff --git a/ui/app/styles/vendors/AdminLTE-fonts.css b/ui/app/styles/vendors/AdminLTE-fonts.css new file mode 100644 index 0000000000..2f77f895b8 --- /dev/null +++ b/ui/app/styles/vendors/AdminLTE-fonts.css @@ -0,0 +1,392 @@ +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url('../fonts/SourceSansPro-Light.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('../fonts/SourceSansPro-Regular.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url('../fonts/SourceSansPro-Semibold.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('../fonts/SourceSansPro-Bold.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url('../fonts/SourceSansPro-LightIt.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('../fonts/SourceSansPro-It.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url('../fonts/SourceSansPro-SemiboldIt.otf') format('truetype'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; +} diff --git a/ui/app/styles/vendors/AdminLTE.css b/ui/app/styles/vendors/AdminLTE.css index 29e9f524a2..40c89f0f9c 100644 --- a/ui/app/styles/vendors/AdminLTE.css +++ b/ui/app/styles/vendors/AdminLTE.css @@ -1,4 +1,4 @@ -@import url(https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700,300italic,400italic,600italic); +@import url(AdminLTE-fonts.css); /*! * AdminLTE v2.3.7 * Author: Almsaeed Studio diff --git a/ui/app/views/components/header.component.html b/ui/app/views/components/header.component.html index 3de2d0e7ac..a80d310b20 100644 --- a/ui/app/views/components/header.component.html +++ b/ui/app/views/components/header.component.html @@ -134,6 +134,12 @@ Observables +
  • + + + Case custom fields + +
  • diff --git a/ui/app/views/partials/alert/event.similarity.html b/ui/app/views/partials/alert/event.similarity.html new file mode 100644 index 0000000000..e12fc07084 --- /dev/null +++ b/ui/app/views/partials/alert/event.similarity.html @@ -0,0 +1,79 @@ +
    + All ({{dialog.event.similarCases.length || 0}}) + + {{dialog.CaseResolutionStatus[statsItem.key] || statsItem.key}} ({{statsItem.value}}) +
    + +
    +
    Title
    +
    Date
    +
    Observables
    +
    IOCs
    +
    Action
    +
    + +
    +
    + +
    + + +
    + +
    + + None + {{tag}} +
    +
    + + (Closed at {{item.endDate | showDate}} as {{dialog.CaseResolutionStatus[item.resolutionStatus]}}) + +
    + +
    + +
    +
    + +
    +
    + +
    + {{item.startDate | shortDate}} +
    + +
    +
    + {{(item.similarArtifactCount / item.artifactCount) | percentage:0}} ({{item.similarArtifactCount}} / {{item.artifactCount}}) + +
    +
    +
    +
    + {{(item.similarIocCount / item.iocCount) | percentage:0}} ({{item.similarIocCount}} / {{item.iocCount}}) + +
    +
    + N/A +
    +
    + +
    + +
    +
    +
    diff --git a/ui/app/views/partials/alert/list/filters.html b/ui/app/views/partials/alert/list/filters.html index 84b18afd46..44a5bba987 100644 --- a/ui/app/views/partials/alert/list/filters.html +++ b/ui/app/views/partials/alert/list/filters.html @@ -30,7 +30,7 @@

    Filters

    diff --git a/ui/app/views/partials/alert/list/toolbar.html b/ui/app/views/partials/alert/list/toolbar.html index 9737acd6e0..13a4ea8db7 100644 --- a/ui/app/views/partials/alert/list/toolbar.html +++ b/ui/app/views/partials/alert/list/toolbar.html @@ -58,10 +58,10 @@ Oldest first
  • - High Severity first + High Severity first
  • - Low Severity first + Low Severity first
  • diff --git a/ui/app/views/partials/case/case.add.field.html b/ui/app/views/partials/case/case.add.field.html new file mode 100644 index 0000000000..607ff65296 --- /dev/null +++ b/ui/app/views/partials/case/case.add.field.html @@ -0,0 +1,15 @@ + + + diff --git a/ui/app/views/partials/case/case.add.metric.html b/ui/app/views/partials/case/case.add.metric.html index 2ef1275bd7..93f26cf853 100644 --- a/ui/app/views/partials/case/case.add.metric.html +++ b/ui/app/views/partials/case/case.add.metric.html @@ -3,7 +3,7 @@ diff --git a/ui/app/views/partials/case/case.details.html b/ui/app/views/partials/case/case.details.html index 3ef38edcd9..def219304b 100644 --- a/ui/app/views/partials/case/case.details.html +++ b/ui/app/views/partials/case/case.details.html @@ -1,6 +1,6 @@
    -

    Basic information

    +

    Summary

    Severity
    @@ -71,7 +71,6 @@

    Basic information

    -
    Description
    @@ -79,79 +78,11 @@

    Basic information

    -
    - -
    -

    - Metrics - - - - Add metric - - - - -

    - -
    - No metrics need to be set -
    +
    -
    -
    {{metricsCache[k].title}}
    -
    - -
    -
    -
    + + +
    diff --git a/ui/app/views/partials/case/case.links.html b/ui/app/views/partials/case/case.links.html index ec73c5a44d..efb7f436bb 100644 --- a/ui/app/views/partials/case/case.links.html +++ b/ui/app/views/partials/case/case.links.html @@ -1,42 +1,89 @@ -