diff --git a/.scalariform.conf b/.scalariform.conf new file mode 100644 index 0000000000..d86296affb --- /dev/null +++ b/.scalariform.conf @@ -0,0 +1,30 @@ +#alignArguments=false +#alignParameters=false +alignSingleLineCaseStatements=true +alignSingleLineCaseStatements.maxArrowIndent=60 +#allowParamGroupsOnNewlines=false +compactControlReadability=true +#compactStringConcatenation=false +#danglingCloseParenthesis=Prevent +doubleIndentClassDeclaration=false +doubleIndentConstructorArguments=true +doubleIndentMethodDeclaration=true +#firstArgumentOnNewline=Force +#firstParameterOnNewline=Force +#formatXml=true +#indentLocalDefs=false +#indentPackageBlocks=true +#indentSpaces=2 +#indentWithTabs=false +#multilineScaladocCommentsStartOnFirstLine=false +#newlineAtEndOfFile=false +placeScaladocAsterisksBeneathSecondAsterisk=true +#preserveSpaceBeforeArguments=false +rewriteArrowSymbols=true +#singleCasePatternOnNewline=true +#spaceBeforeColon=false +#spaceBeforeContextColon=false +#spaceInsideBrackets=false +#spaceInsideParentheses=false +#spacesAroundMultiImports=true +#spacesWithinPatternBinders=true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f746f3754c..a9c9bd61bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,55 @@ # Change Log -## [2.13.2](https://github.com/CERT-BDF/TheHive/tree/2.13.2) (2017-10-24) +## [3.0.0](https://github.com/CERT-BDF/TheHive/tree/3.0.0) (2017-12-05) + +[Full Changelog](https://github.com/CERT-BDF/TheHive/compare/2.13.2...3.0.0) + +**Implemented enhancements:** + +- Assign default values to case templates' custom fields [\#375](https://github.com/CERT-BDF/TheHive/issues/375) +- Add the Ability to Import and Export Case Templates [\#369](https://github.com/CERT-BDF/TheHive/issues/369) +- Add a sighted flag for IOCs [\#365](https://github.com/CERT-BDF/TheHive/issues/365) +- Alert id should not be used to build case title when using case templates [\#364](https://github.com/CERT-BDF/TheHive/issues/364) +- Set task assignee in case template [\#362](https://github.com/CERT-BDF/TheHive/issues/362) +- Add Autonomous Systems to the Default Datatype List [\#359](https://github.com/CERT-BDF/TheHive/issues/359) +- Display more than 10 users per page and sort them by alphanumerical order [\#346](https://github.com/CERT-BDF/TheHive/issues/346) +- \[Minor\] Add user dialog title issue [\#345](https://github.com/CERT-BDF/TheHive/issues/345) +- Deleted cases showing in statistics [\#317](https://github.com/CERT-BDF/TheHive/issues/317) +- Dynamic dashboard [\#312](https://github.com/CERT-BDF/TheHive/issues/312) +- Add health check in status API [\#306](https://github.com/CERT-BDF/TheHive/issues/306) +- Alerts in Statistics [\#274](https://github.com/CERT-BDF/TheHive/issues/274) +- Statistics: Observables and IOC over time [\#215](https://github.com/CERT-BDF/TheHive/issues/215) +- Export Statistics/Metrics [\#197](https://github.com/CERT-BDF/TheHive/issues/197) +- Msg\_Parser analyser show for all files [\#184](https://github.com/CERT-BDF/TheHive/issues/184) +- Assign default metric values [\#176](https://github.com/CERT-BDF/TheHive/issues/176) +- Display Cortex Version, Instance Name, Status and Available Analyzers [\#130](https://github.com/CERT-BDF/TheHive/issues/130) +- Feature Request: Webhooks [\#20](https://github.com/CERT-BDF/TheHive/issues/20) +- Remove the From prefix and template suffix around a template name in the New Case menu [\#348](https://github.com/CERT-BDF/TheHive/issues/348) +- Keep the alert date when creating a case from it [\#320](https://github.com/CERT-BDF/TheHive/issues/320) +- Export to MISP: add TLP [\#314](https://github.com/CERT-BDF/TheHive/issues/314) +- Show already known observables in Import MISP Events preview window [\#137](https://github.com/CERT-BDF/TheHive/issues/137) + +**Fixed bugs:** + +- The misp \> instance name \> tags parameter is not honored when importing MISP events [\#373](https://github.com/CERT-BDF/TheHive/issues/373) +- \[Bug\] Merging an alert into case with duplicate artifacts does not merge descriptions [\#357](https://github.com/CERT-BDF/TheHive/issues/357) +- Share a case if MISP is not enabled raise an error [\#349](https://github.com/CERT-BDF/TheHive/issues/349) +- Validate alert's TLP and severity attributes values [\#326](https://github.com/CERT-BDF/TheHive/issues/326) +- Merge of cases overrides task log owners [\#303](https://github.com/CERT-BDF/TheHive/issues/303) +**Closed issues:** + +- MISP Connection Error with Cortex/HIVE [\#371](https://github.com/CERT-BDF/TheHive/issues/371) +- Single Sign-On with X.509 certificates [\#297](https://github.com/CERT-BDF/TheHive/issues/297) +- Remove the deprecated "user" property [\#316](https://github.com/CERT-BDF/TheHive/issues/316) +- Run observable analyzers through API [\#308](https://github.com/CERT-BDF/TheHive/issues/308) + +**Merged pull requests:** + +- typos and improvements to text [\#355](https://github.com/CERT-BDF/TheHive/pull/355) ([steoleary](https://github.com/steoleary)) +- Correct typo [\#353](https://github.com/CERT-BDF/TheHive/pull/353) ([arnydo](https://github.com/arnydo)) + +## [2.13.2](https://github.com/CERT-BDF/TheHive/tree/2.13.2) (2017-10-24) [Full Changelog](https://github.com/CERT-BDF/TheHive/compare/2.13.1...2.13.2) **Fixed bugs:** diff --git a/build.sbt b/build.sbt index 1d05af0bde..a30eaaeb33 100644 --- a/build.sbt +++ b/build.sbt @@ -18,21 +18,18 @@ lazy val thehiveCortex = (project in file("thehive-cortex")) .enablePlugins(PlayScala) .dependsOn(thehiveBackend) .settings(publish := {}) - .settings(SbtScalariform.scalariformSettings: _*) lazy val thehive = (project in file(".")) .enablePlugins(PlayScala) + .enablePlugins(PublishToBinTray) .dependsOn(thehiveBackend, thehiveMetrics, thehiveMisp, thehiveCortex) .aggregate(thehiveBackend, thehiveMetrics, thehiveMisp, thehiveCortex) .settings(aggregate in Debian := false) .settings(aggregate in Rpm := false) .settings(aggregate in Docker := false) - .settings(PublishToBinTray.settings: _*) - .settings(Release.settings: _*) - // Redirect logs from ElasticSearch (which uses log4j2) to slf4j -libraryDependencies += "org.apache.logging.log4j" % "log4j-to-slf4j" % "2.9.0" +libraryDependencies += "org.apache.logging.log4j" % "log4j-to-slf4j" % "2.9.1" excludeDependencies += "org.apache.logging.log4j" % "log4j-core" lazy val rpmPackageRelease = (project in file("package/rpm-release")) @@ -58,10 +55,6 @@ lazy val rpmPackageRelease = (project in file("package/rpm-release")) )) ) - -Release.releaseVersionUIFile := baseDirectory.value / "ui" / "package.json" -Release.changelogFile := baseDirectory.value / "CHANGELOG.md" - // Front-end // run := { (run in Compile).evaluated @@ -81,8 +74,8 @@ mappings in Universal ~= { file("package/thehive.service") -> "package/thehive.service", file("package/thehive.conf") -> "package/thehive.conf", file("package/thehive") -> "package/thehive", - file("package/logback.xml") -> "conf/logback.xml" - ) + file("package/logback.xml") -> "conf/logback.xml", + ) ++ (file("migration").**(AllPassFilter) pair Path.rebase(file("migration"), "migration")) } // Package // @@ -122,7 +115,7 @@ packageBin := { } // DEB // linuxPackageMappings in Debian += packageMapping(file("LICENSE") -> "/usr/share/doc/thehive/copyright").withPerms("644") -version in Debian := version.value + "-1" +version in Debian := version.value + "-0" debianPackageRecommends := Seq("elasticsearch") debianPackageDependencies += "openjdk-8-jre-headless" maintainerScripts in Debian := maintainerScriptsFromDirectory( @@ -146,11 +139,13 @@ linuxPackageSymlinks in Rpm := Nil rpmPrefix := Some(defaultLinuxInstallLocation.value) linuxEtcDefaultTemplate in Rpm := (baseDirectory.value / "package" / "etc_default_thehive").asURL rpmReleaseFile := { + import scala.sys.process._ val rpmFile = (packageBin in Rpm in rpmPackageRelease).value s"rpm --addsign $rpmFile".!! rpmFile } packageBin in Rpm := { + import scala.sys.process._ val rpmFile = (packageBin in Rpm).value s"rpm --addsign $rpmFile".!! rpmFile @@ -192,42 +187,8 @@ bintrayOrganization := Some("cert-bdf") bintrayRepository := "thehive" publish := { (publish in Docker).value - PublishToBinTray.publishRelease.value - PublishToBinTray.publishLatest.value - PublishToBinTray.publishRpm.value - PublishToBinTray.publishDebian.value + publishRelease.value + publishLatest.value + publishRpm.value + publishDebian.value } - -// Scalariform // -import scalariform.formatter.preferences._ -import com.typesafe.sbt.SbtScalariform.ScalariformKeys - -ScalariformKeys.preferences in ThisBuild := ScalariformKeys.preferences.value - .setPreference(AlignParameters, false) - // .setPreference(FirstParameterOnNewline, Force) - .setPreference(AlignArguments, true) - // .setPreference(FirstArgumentOnNewline, true) - .setPreference(AlignSingleLineCaseStatements, true) - .setPreference(AlignSingleLineCaseStatements.MaxArrowIndent, 60) - .setPreference(CompactControlReadability, true) - .setPreference(CompactStringConcatenation, false) - .setPreference(DoubleIndentClassDeclaration, true) - // .setPreference(DoubleIndentMethodDeclaration, true) - .setPreference(FormatXml, true) - .setPreference(IndentLocalDefs, false) - .setPreference(IndentPackageBlocks, false) - .setPreference(IndentSpaces, 2) - .setPreference(IndentWithTabs, false) - .setPreference(MultilineScaladocCommentsStartOnFirstLine, false) - // .setPreference(NewlineAtEndOfFile, true) - .setPreference(PlaceScaladocAsterisksBeneathSecondAsterisk, false) - .setPreference(PreserveSpaceBeforeArguments, false) - // .setPreference(PreserveDanglingCloseParenthesis, false) - .setPreference(DanglingCloseParenthesis, Prevent) - .setPreference(RewriteArrowSymbols, true) - .setPreference(SpaceBeforeColon, false) - // .setPreference(SpaceBeforeContextColon, false) - .setPreference(SpaceInsideBrackets, false) - .setPreference(SpaceInsideParentheses, false) - .setPreference(SpacesWithinPatternBinders, true) - .setPreference(SpacesAroundMultiImports, true) diff --git a/conf/application.sample b/conf/application.sample index 8aee0f4783..50b0dd5a20 100644 --- a/conf/application.sample +++ b/conf/application.sample @@ -1,52 +1,60 @@ -# Secret key -# ~~~~~ -# The secret key is used to secure cryptographics functions. -# If you deploy your application to several instances be sure to use the same key! +# Secret Key +# The secret key is used to secure cryptographic functions. +# WARNING: If you deploy your application on several servers, make sure to use the same key. #play.crypto.secret="***changeme***" - -# ElasticSearch +# Elasticsearch search { - # Name of the index + # Index name. index = the_hive - # Name of the ElasticSearch cluster + # ElasticSearch cluster name. cluster = hive - # Address of the ElasticSearch instance + # ElasticSearch instance address. host = ["127.0.0.1:9300"] - # Scroll keepalive + # Scroll keepalive. keepalive = 1m - # Size of the page for scroll + # Scroll page size. pagesize = 50 } # Authentication auth { - # "type" parameter contains authentication provider. It can be multi-valued (useful for migration) - # available auth types are: - # services.LocalAuthSrv : passwords are stored in user entity (in ElasticSearch). No configuration are required. - # ad : use ActiveDirectory to authenticate users. Configuration is under "auth.ad" key - # ldap : use LDAP to authenticate users. Configuration is under "auth.ldap" key + # "type" parameter contains the authentication provider(s). It can be multi-valued, which is useful + # for migration. + # The available auth types are: + # - services.LocalAuthSrv : passwords are stored in the user entity within ElasticSearch). No + # configuration are required. + # - ad : use ActiveDirectory to authenticate users. The associated configuration shall be done in + # the "ad" section below. + # - ldap : use LDAP to authenticate users. The associated configuration shall be done in the + # "ldap" section below. type = [local] ad { - # Domain Windows name using DNS format. This parameter is required. + # The Windows domain name in DNS format. This parameter is required if you do not use + # 'serverNames' below. #domainFQDN = "mydomain.local" - # Domain Windows name using short format. This parameter is required. + # Optionally you can specify the host names of the domain controllers instead of using 'domainFQDN + # above. If this parameter is not set, TheHive uses 'domainFQDN'. + #serverNames = [ad1.mydomain.local, ad2.mydomain.local] + + # The Windows domain name using short format. This parameter is required. #domainName = "MYDOMAIN" - # Use SSL to connect to domain controller + # If 'true', use SSL to connect to the domain controller. #useSSL = true } ldap { - # LDAP server name or address. Port can be specified (host:port). This parameter is required. + # The LDAP server name or address. The port can be specified using the 'host:port' + # syntax. This parameter is required if you don't use 'serverNames' below. #serverName = "ldap.mydomain.local:389" - # Use SSL to connect to directory server - #useSSL = true + # If you have multiple LDAP servers, use the multi-valued setting 'serverNames' instead. + #serverNames = [ldap1.mydomain.local, ldap2.mydomain.local] - # Account to use to bind on LDAP server. This parameter is required. + # Account to use to bind to the LDAP server. This parameter is required. #bindDN = "cn=thehive,ou=services,dc=mydomain,dc=local" # Password of the binding account. This parameter is required. @@ -55,33 +63,66 @@ auth { # Base DN to search users. This parameter is required. #baseDN = "ou=users,dc=mydomain,dc=local" - # Filter to search user {0} is replaced by user name. This parameter is required. + # Filter to search user in the directory server. Please note that {0} is replaced + # by the actual user name. This parameter is required. #filter = "(cn={0})" + + # If 'true', use SSL to connect to the LDAP directory server. + #useSSL = true } } # Cortex +# TheHive can connect to one or multiple Cortex instances. Give each +# Cortex instance a name and specify the associated URL. + cortex { #"CORTEX-SERVER-ID" { - # # URL of MISP server - # url = "" + # URL of the Cortex server. + #url = "" #} } # MISP +# TheHive can connect to one or multiple MISP instances. Give each MISP +# instance a name and specify the associated Authkey that must be used +# to poll events, the case template that should be used by default when +# importing events as well as the tags that must be added to cases upon +# import. + +# Prior to configuring the integration with a MISP instance, you must +# enable the MISP connector. This will allow you to import events to +# and/or export cases to the MISP instance(s). +#play.modules.enabled += connectors.misp.MispConnector + misp { #"MISP-SERVER-ID" { - # # URL of MISP server - # url = "" - # # authentication key - # key = "" - # #tags to be added to imported artifact - # tags = ["misp"] - #} + # URL of the MISP instance. + #url = "" + + # Authentication key. + #key = "" + + # Name of the case template in TheHive that shall be used to import + # MISP events as cases by default. + # caseTemplate = "" - # truststore to used to validate MISP certificate (if default truststore is not suffisient) - #cert = /path/to/truststore.jsk + # Tags to add to each observable imported from an event available on + # this instance. + #tags = ["misp-server-id"] + + # Truststore to use to validate the X.509 certificate of the MISP + # instance if the default truststore is not sufficient. + + #ws.ssl.trustManager.stores = [ + #{ + # type: "JKS" + # path: "/path/to/truststore.jks" + #} + #] + #} - # Interval between two MISP event import + # Interval between consecutive MISP event imports in hours (h) or + # minutes (m). interval = 1h } diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index bef8212009..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,37 +0,0 @@ -TheHive is a scalable 3-in-1 open source and free security incident response platform designed to make life easier for SOCs, CSIRTs, CERTs and any information security practitioner dealing with security incidents that need to be investigated and acted upon swiftly. - -## Hardware Pre-requisites - -TheHive uses ElasticSearch to store data. Both software use a Java VM. We recommend using a virtual machine with 8vCPU, 8 -GB of RAM and 60 GB of disk. You can also use a physical machine with similar specifications. - -## What's New? - -- [Changelog](/CHANGELOG.md) -- [Migration guide](migration-guide.md) - -## Installation Guides - -TheHive can be installed using: -- An [RPM package](installation/rpm-guide.md) -- A [DEB package](installation/deb-guide.md) -- [Docker](installation/docker-guide.md) -- [Binary](installation/binary-guide.md) -- [Ansible script](https://github.com/drewstinnett/ansible-thehive) contributed by -[@drewstinnett](https://github.com/drewstinnett) - -TheHive can also be [built from sources](installation/build-guide.md). - -## Administration Guides - -- [Administrator's guide](admin/admin-guide.md) -- [Configuration guide](admin/configuration.md) -- [Updating](admin/updating.md) -- [Backup & Restore](admin/backup-restore.md) - -## Developer Guides - -- [API documentation](api/README.md) - -## Other -- [FAQ](FAQ.md) diff --git a/docs/installation/rpm-guide.md b/docs/installation/rpm-guide.md deleted file mode 100644 index f701a2a9a6..0000000000 --- a/docs/installation/rpm-guide.md +++ /dev/null @@ -1,19 +0,0 @@ -# Installing TheHive Using an RPM Package - -TheHive's RPM packages are published on our Bintray repository. All packages are PGP signed using the key which ID is [562CBC1C](/PGP-PUBLIC-KEY). The key's fingerprint is: - -```0CD5 AC59 DE5C 5A8E 0EE1 3849 3D99 BB18 562C BC1C``` - -To intall TheHive from an RPM package, you'll need to begin by installing the RPM release package using the following command: -``` -yum install install https://dl.bintray.com/cert-bdf/rpm/thehive-project-release-1.0.0-3.noarch.rpm -``` -This will install TheHive Project's repository in `/etc/yum.repos.d/thehive-rpm.repo` and the GPG public key `in -/etc/pki/rpm-gpg/GPG-TheHive-Project`. - -Once done, you will able to install TheHive package using yum: -``` -yum install thehive -``` - -One installed, you should [install ElasticSearch](elasticsearch-guide.md) and [configure TheHive](../admin/configuration.md). diff --git a/migration/12/dashboards/Alert_statistics.json b/migration/12/dashboards/Alert_statistics.json new file mode 100644 index 0000000000..394e9d0cf9 --- /dev/null +++ b/migration/12/dashboards/Alert_statistics.json @@ -0,0 +1 @@ +{"title":"Alert statistics","definition":{"period":"last3Months","items":[{"type":"container","items":[{"type":"donut","options":{"title":"Alerts by status","entity":"alert","field":"status","query":{},"names":{"New":"New","Updated":"Updated","Ignored":"Ignored","Imported":"Imported"}},"id":"cd063f98-21cc-405c-18a9-af669acae104"},{"type":"donut","options":{"title":"Waiting alerts by type","entity":"alert","field":"type","filters":[{"field":"status","type":"enumeration","value":{"list":[{"text":"New","label":"New"},{"text":"Updated","label":"Updated"}]}}],"query":{"_or":[{"_field":"status","_value":"New"},{"_field":"status","_value":"Updated"}]},"names":{}},"id":"8ca4226f-374e-5315-71b8-5d6a4141d886"},{"type":"donut","options":{"title":"Waiting alerts by source","entity":"alert","field":"source","filters":[{"field":"status","type":"enumeration","value":{"list":[{"text":"New","label":"New"},{"text":"Updated","label":"Updated"}]}}],"query":{"_or":[{"_field":"status","_value":"New"},{"_field":"status","_value":"Updated"}]},"names":{}},"id":"73a986bb-7f53-fc62-6cc8-1e099fadc4b4"}]},{"type":"container","items":[{"type":"bar","options":{"entity":"alert","dateField":"createdAt","interval":"1w","field":"type","stacked":true,"title":"Alert type history","query":{},"names":{}},"id":"62633389-0aa0-827b-ef48-e5bedf7d5e7d"},{"type":"bar","options":{"title":"Alert source history","entity":"alert","dateField":"createdAt","interval":"1w","field":"source","stacked":true,"query":{},"names":{}},"id":"a513f977-e743-9862-0755-9831e9bf080a"}]}],"customPeriod":{"fromDate":null,"toDate":null}},"description":"Alert statistics","status":"Shared"} \ No newline at end of file diff --git a/migration/12/dashboards/Case_statistics.json b/migration/12/dashboards/Case_statistics.json new file mode 100644 index 0000000000..3a386d1ad3 --- /dev/null +++ b/migration/12/dashboards/Case_statistics.json @@ -0,0 +1,244 @@ +{ + "description": "case", + "title": "Case statistics", + "definition": { + "period": "last3Months", + "items": [{ + "type": "container", + "items": [{ + "type": "donut", + "options": { + "title": "Owner of open cases", + "entity": "case", + "field": "owner", + "filters": [{ + "field": "status", + "type": "enumeration", + "value": { + "list": [{ + "text": "Open", + "label": "Open" + }] + } + }], + "query": { + "_field": "status", + "_value": "Open" + }, + "names": {} + }, + "id": "4cb4f7d3-eb21-dd61-2a6f-85cf096a2a6e" + }, { + "type": "donut", + "options": { + "title": "Cases by status", + "entity": "case", + "field": "status", + "filters": [], + "names": { + "NoImpact": "NoImpact", + "WithImpact": "WithImpact", + "NotApplicable": "NotApplicable", + "Open": "Open", + "Resolved": "Resolved", + "Deleted": "Deleted" + }, + "query": {} + }, + "id": "84b81a65-4b3c-2b26-421e-fd7453d92f3e" + }] + }, { + "type": "container", + "items": [{ + "type": "donut", + "options": { + "title": "Revolved cases by resolution", + "entity": "case", + "field": "resolutionStatus", + "filters": [{ + "field": "status", + "type": "enumeration", + "value": { + "list": [{ + "text": "Resolved", + "label": "Resolved" + }] + } + }], + "query": { + "_field": "status", + "_value": "Resolved" + }, + "names": { + "FalsePositive": "FalsePositive", + "Duplicated": "Duplicated", + "Indeterminate": "Indeterminate", + "TruePositive": "TruePositive", + "Other": "Other" + } + }, + "id": "ede6e87a-2e39-5556-b421-1c4cd73a74b1" + }, { + "type": "donut", + "options": { + "title": "Case tags", + "entity": "case", + "field": "tags", + "query": {}, + "names": {} + }, + "id": "a9e47a5d-3c84-4949-b941-a60ea3c41e81" + }] + }, { + "type": "container", + "items": [{ + "type": "bar", + "options": { + "entity": "case", + "dateField": "createdAt", + "interval": "1w", + "field": "owner", + "stacked": true, + "query": {}, + "names": {}, + "title": "Case owner history" + }, + "id": "b5bb88c6-0a76-ca85-c4b6-5096199ddf80" + }, { + "type": "bar", + "options": { + "entity": "case", + "dateField": "createdAt", + "interval": "1w", + "field": "severity", + "stacked": true, + "query": {}, + "names": { + "1": "low", + "2": "medium", + "3": "high" + }, + "title": "Case severity history" + }, + "id": "9bdac0ad-441b-2be3-9e6e-342968be5315" + }, { + "type": "bar", + "options": { + "entity": "case", + "dateField": "createdAt", + "interval": "1w", + "field": "tlp", + "stacked": true, + "title": "Case TLP history", + "query": {}, + "names": { + "0": "white", + "1": "green", + "2": "amber", + "3": "red" + } + }, + "id": "72157fd6-efb4-cf0c-a281-7eacc3c32a4f" + }] + }, { + "type": "container", + "items": [{ + "type": "line", + "options": { + "title": "Case over time", + "entity": "case", + "field": "createdAt", + "interval": "1w", + "series": [{ + "agg": "avg", + "field": "computed.handlingDurationInHours", + "type": "line", + "filters": [{ + "field": "status", + "type": "enumeration", + "value": { + "list": [{ + "text": "Resolved", + "label": "Resolved" + }] + } + }], + "query": { + "_field": "status", + "_value": "Resolved" + } + }, { + "agg": "count", + "field": null, + "type": "bar" + }], + "query": {} + }, + "id": "377784a7-49c2-50aa-2eba-acc862a0b841" + }] + }, { + "type": "container", + "items": [{ + "type": "donut", + "options": { + "title": "TLP of open cases", + "entity": "case", + "field": "tlp", + "filters": [{ + "field": "status", + "type": "enumeration", + "value": { + "list": [{ + "text": "Open", + "label": "Open" + }] + } + }], + "query": { + "_field": "status", + "_value": "Open" + }, + "names": { + "0": "white", + "1": "green", + "2": "amber", + "3": "red" + } + }, + "id": "4c7bb013-c87f-7f17-0892-e20af2a0dcac" + }, { + "type": "donut", + "options": { + "title": "Severity of open cases", + "entity": "case", + "field": "severity", + "filters": [{ + "field": "status", + "type": "enumeration", + "value": { + "list": [{ + "text": "Open", + "label": "Open" + }] + } + }], + "query": { + "_field": "status", + "_value": "Open" + }, + "names": { + "1": "low", + "2": "medium", + "3": "high" + } + }, + "id": "d943c6f4-61d8-b4dd-7a3a-56067829727a" + }] + }], + "customPeriod": { + "fromDate": null, + "toDate": null + } + }, + "status": "Shared" +} diff --git a/migration/12/dashboards/Job_statistics.json b/migration/12/dashboards/Job_statistics.json new file mode 100644 index 0000000000..bebbb327c0 --- /dev/null +++ b/migration/12/dashboards/Job_statistics.json @@ -0,0 +1 @@ +{"definition":{"period":"last3Months","items":[{"type":"container","items":[{"type":"donut","options":{"title":"Top analyzers","entity":"case_artifact_job","field":"analyzerId","query":{},"names":{}},"id":"1eaa4dfa-5b14-50b6-e442-8729363f6f66"},{"type":"donut","options":{"title":"Cortex instance use","entity":"case_artifact_job","field":"cortexId","query":{},"names":{}},"id":"c501c2d3-9779-1d2a-6d85-bb2bd68260f5"}]},{"type":"container","items":[{"type":"bar","options":{"title":"Job owners","entity":"case_artifact_job","dateField":"createdAt","interval":"1w","field":"createdBy","stacked":true,"query":{},"names":{}},"id":"bc10b554-aa4c-6fce-c4bb-b906b9b0e398"},{"type":"bar","options":{"title":"Analyzer history","entity":"case_artifact_job","dateField":"createdAt","interval":"1w","field":"analyzerId","stacked":true,"query":{},"names":{}},"id":"cd6d0dc1-a77d-be9d-e7dd-c6a8c79b0898"}]}],"customPeriod":{"fromDate":null,"toDate":null}},"title":"Job statistics","status":"Shared","description":"Job statistics"} \ No newline at end of file diff --git a/migration/12/dashboards/Observable_statistics .json b/migration/12/dashboards/Observable_statistics .json new file mode 100644 index 0000000000..ccaba5b0f6 --- /dev/null +++ b/migration/12/dashboards/Observable_statistics .json @@ -0,0 +1 @@ +{"definition":{"period":"last3Months","items":[{"type":"container","items":[{"type":"donut","options":{"title":"Observables by type","entity":"case_artifact","field":"dataType","query":{},"names":{"fqdn":"fqdn","url":"url","regexp":"regexp","mail":"mail","hash":"hash","registry":"registry","uri_path":"uri_path","truc":"truc","ip":"ip","user-agent":"user-agent","autonomous-system":"autonomous-system","file":"file","mail_subject":"mail_subject","filename":"filename","other":"other","domain":"domain"}},"id":"6ee86a99-3f40-1960-fd4d-398a1da5b76e"},{"type":"donut","options":{"title":"Observables by attachment content type","entity":"case_artifact","field":"attachment.contentType","query":{"_field":"dataType","_value":"file"},"names":{},"filters":[{"field":"dataType","type":"enumeration","value":{"list":[{"text":"file","label":"file"}]}}]},"id":"b6110238-3074-4e85-674f-4bc56829e68a"}]},{"type":"container","items":[{"type":"donut","options":{"title":"Observable tags","entity":"case_artifact","field":"tags","query":{},"names":{}},"id":"70bbc0a5-1692-4e46-ebac-8769952ad9c0"},{"type":"donut","options":{"title":"Observables by TLP","entity":"case_artifact","field":"tlp","query":{},"names":{"0":"white","1":"green","2":"amber","3":"red"},"colors":{"0":"#bdf0ea","1":"#48e80f","2":"#e0a91a","3":"#f02626"}},"id":"633fbe97-805e-6123-3330-29f5c8f45f13"}]},{"type":"container","items":[{"type":"donut","options":{"title":"Observables by IOC flag","entity":"case_artifact","field":"ioc","query":{},"names":{}},"id":"771a3bdf-e437-ac3a-384d-23be91a25b07"},{"type":"line","options":{"title":"Observables over time","entity":"case_artifact","field":"createdAt","interval":"1w","series":[{"agg":"count","field":null,"type":"area-spline","filters":[{"field":"ioc","type":"boolean","value":true}],"label":"IOC","query":{"_field":"ioc","_value":true}},{"agg":"count","field":null,"type":"area-spline","label":"non-IOC","filters":[{"field":"ioc","type":"boolean","value":false}],"query":{"_field":"ioc","_value":false}}],"stacked":true,"query":{}},"id":"e5ed24a6-51ed-ecc4-9db0-ce837fd84214"}]}],"customPeriod":{"fromDate":null,"toDate":null}},"status":"Shared","title":"Observable statistics","description":"Observable statistics"} \ No newline at end of file diff --git a/project/Bintray.scala b/project/Bintray.scala index 2550dfb94f..bfd6ea4258 100644 --- a/project/Bintray.scala +++ b/project/Bintray.scala @@ -1,6 +1,5 @@ import java.io.File -import PublishToBinTray.publishRpm import bintray.BintrayCredentials import bintray.BintrayKeys.{ bintrayEnsureCredentials, bintrayOrganization, bintrayPackage, bintrayRepository } import bintry.Client @@ -16,15 +15,19 @@ import scala.concurrent.Await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.Duration -object PublishToBinTray extends Plugin { - val publishRelease = taskKey[Unit]("Publish binary in Bintray") - val publishLatest = taskKey[Unit]("Publish latest binary in Bintray") - val publishDebian = taskKey[Unit]("publish debian package in Bintray") - val publishRpm = taskKey[Unit]("publish rpm package in Bintray") - val rpmReleaseFile = taskKey[File]("The rpm release package file") - val publishRpmRelease = taskKey[Unit]("publish rpm release package in Bintray") +object PublishToBinTray extends AutoPlugin { + object autoImport { + val publishRelease: TaskKey[Unit] = taskKey[Unit]("Publish binary in bintray") + val publishLatest: TaskKey[Unit] = taskKey[Unit]("Publish latest binary in bintray") + val publishDebian: TaskKey[Unit] = taskKey[Unit]("publish debian package in Bintray") + val publishRpm: TaskKey[Unit] = taskKey[Unit]("publish rpm package in Bintray") + val rpmReleaseFile = taskKey[File]("The rpm release package file") + val publishRpmRelease = taskKey[Unit]("publish rpm release package in Bintray") - override def settings = Seq( + } + import autoImport._ + + override lazy val projectSettings = Seq( publishRelease in ThisBuild := { val file = (packageBin in Universal).value btPublish(file.getName, @@ -36,12 +39,14 @@ object PublishToBinTray extends Plugin { version.value, sLog.value) }, - publishLatest in ThisBuild := { + publishLatest in ThisBuild := Def.taskDyn { val file = (packageBin in Universal).value val latestName = file.getName.replace(version.value, "latest") if (latestName == file.getName) - sLog.value.warn(s"Latest package name can't be built using package name [$latestName], publish aborted") - else { + Def.task { + sLog.value.warn(s"Latest package name can't be built using package name [$latestName], publish aborted") + } + else Def.task { removeVersion(bintrayEnsureCredentials.value, bintrayOrganization.value, bintrayRepository.value, @@ -56,7 +61,8 @@ object PublishToBinTray extends Plugin { "latest", sLog.value) } - }, + } + .value, publishDebian in ThisBuild := { val file = (debianSign in Debian).value btPublish(file.getName, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 9c0b6375ed..307c3e3918 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,7 +1,7 @@ import sbt._ object Dependencies { - val scalaVersion = "2.12.3" + val scalaVersion = "2.12.4" object Library { @@ -14,24 +14,12 @@ object Dependencies { val specs2 = "com.typesafe.play" %% "play-specs2" % version val filters = "com.typesafe.play" %% "filters-helpers" % version val guice = "com.typesafe.play" %% "play-guice" % version - object Specs2 { - private val version = "3.6.6" - val matcherExtra = "org.specs2" %% "specs2-matcher-extra" % version - val mock = "org.specs2" %% "specs2-mock" % version - } } - object Specs2 { - private val version = "3.9.4" - val core = "org.specs2" %% "specs2-core" % version - val matcherExtra = "org.specs2" %% "specs2-matcher-extra" % version - val mock = "org.specs2" %% "specs2-mock" % version - } val scalaGuice = "net.codingwell" %% "scala-guice" % "4.1.0" - val akkaTestkit = "com.typesafe.akka" %% "akka-testkit" % "2.5.4" + val reflections = "org.reflections" % "reflections" % "0.9.11" val zip4j = "net.lingala.zip4j" % "zip4j" % "1.3.2" - val akkaTest = "com.typesafe.akka" %% "akka-stream-testkit" % "2.5.4" - val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.3.2" + val elastic4play = "org.cert-bdf" %% "elastic4play" % "1.4.0" } } diff --git a/project/FrontEnd.scala b/project/FrontEnd.scala index f0bf553422..c3c7eff737 100644 --- a/project/FrontEnd.scala +++ b/project/FrontEnd.scala @@ -1,5 +1,8 @@ import sbt._ import sbt.Keys._ +import scala.sys.process.Process + +import Path.rebase object FrontEnd extends AutoPlugin { @@ -30,6 +33,6 @@ object FrontEnd extends AutoPlugin { s.log.info("grunt build") Process("grunt" :: "build" :: Nil, baseDirectory.value / "ui") ! s.log val dir = baseDirectory.value / "ui" / "dist" - (dir.***) pair rebase(dir, "ui") + (dir.**(AllPassFilter)) pair rebase(dir, "ui") }) } \ No newline at end of file diff --git a/project/Release.scala b/project/Release.scala deleted file mode 100644 index e9fcffbabb..0000000000 --- a/project/Release.scala +++ /dev/null @@ -1,35 +0,0 @@ -import play.api.libs.json._ -import sbt.Keys.{baseDirectory, sLog, version} -import sbt.{File, IO, _} - -object Release { - val releaseVersionUIFile = settingKey[File]("The json package file to write the version to") - val changelogFile = settingKey[File]("Changelog file") - val updateUIVersion = taskKey[Unit]("Put sbt package version (from version.sbt) in NPM package (package.json)") - val generateChangelog = taskKey[Unit]("Generate changelog file") - - lazy val settings = Seq( - releaseVersionUIFile := baseDirectory.value / "ui" / "package.json", - changelogFile := baseDirectory.value / "CHANGELOG.md", - updateUIVersion := { - val packageFile = releaseVersionUIFile.value - val pkgJson = Json.parse(IO.read(packageFile)) - - pkgJson.transform( - (__ \ 'version).json.update( - __.read[JsString].map(_ => JsString((version in ThisBuild).value)))) match { - case JsSuccess(newPkgJson, _) => IO.write(packageFile, Json.prettyPrint(newPkgJson)) - case JsError(error) => sys.error(s"Invalid package file format: $error") - } - }, - generateChangelog := { - sLog.value.info("Generating changelog in ") - val properties = new java.util.Properties - val credentialsFile = new File("~/.github/credentials") - IO.load(properties, credentialsFile) - val token = Option(properties.getProperty("token")).fold("")(t => s"-t $t") - s"github_changelog_generator $token" ! sLog.value - () - } - ) -} \ No newline at end of file diff --git a/project/build.properties b/project/build.properties index c091b86ca4..9abea1294a 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.16 +sbt.version=1.0.3 diff --git a/project/build.sbt b/project/build.sbt deleted file mode 100644 index 372fd2af72..0000000000 --- a/project/build.sbt +++ /dev/null @@ -1,13 +0,0 @@ -libraryDependencies += "com.typesafe.play" %% "play-json" % "2.4.8" - -scalacOptions in ThisBuild ++= Seq( - "-encoding", "UTF-8", - "-deprecation", // warning and location for usages of deprecated APIs - "-feature", // warning and location for usages of features that should be imported explicitly - "-unchecked", // additional warnings where generated code depends on assumptions - "-Xlint", // recommended additional warnings - "-Ywarn-adapted-args", // Warn if an argument list is modified to match the receiver - "-Ywarn-value-discard", // Warn when non-Unit expression results are unused - "-Ywarn-inaccessible", - "-Ywarn-dead-code" -) diff --git a/project/plugins.sbt b/project/plugins.sbt index b0115921fe..4a38817370 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,12 +1,11 @@ // Comment to get more information during initialization logLevel := Level.Info -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.3") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.7") -addSbtPlugin("me.lessis" % "bintray-sbt" % "0.3.0") +//addSbtPlugin("me.lessis" % "bintray-sbt" % "0.3.0") +addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.5.1") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.1.4") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.2") -addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.3") - -addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.6.0") +addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2") diff --git a/thehive-backend/app/connectors/Connectors.scala b/thehive-backend/app/connectors/Connectors.scala index df5e0ff218..39aa2ab403 100644 --- a/thehive-backend/app/connectors/Connectors.scala +++ b/thehive-backend/app/connectors/Connectors.scala @@ -3,6 +3,7 @@ package connectors import javax.inject.{ Inject, Singleton } import scala.collection.immutable +import scala.concurrent.Future import play.api.libs.json.{ JsObject, Json } import play.api.mvc._ @@ -10,12 +11,14 @@ import play.api.routing.sird.UrlContext import play.api.routing.{ Router, SimpleRouter } import com.google.inject.AbstractModule +import models.HealthStatus import net.codingwell.scalaguice.{ ScalaModule, ScalaMultibinder } trait Connector { val name: String val router: Router - val status: JsObject = Json.obj("enabled" → true) + def status: Future[JsObject] = Future.successful(Json.obj("enabled" → true)) + def health: Future[HealthStatus.Type] = Future.successful(HealthStatus.Ok) } @Singleton diff --git a/thehive-backend/app/controllers/AlertCtrl.scala b/thehive-backend/app/controllers/AlertCtrl.scala index 322f90f3dd..89c5f7a2d7 100644 --- a/thehive-backend/app/controllers/AlertCtrl.scala +++ b/thehive-backend/app/controllers/AlertCtrl.scala @@ -74,9 +74,13 @@ class AlertCtrl @Inject() ( similarCases ← if (withSimilarity) alertSrv.similarCases(alert) .map(sc ⇒ Json.obj("similarCases" → Json.toJson(sc))) - else Future.successful(JsObject(Nil)) + else Future.successful(JsObject.empty) + similarArtifacts ← if (withSimilarity) + alertSrv.alertArtifactsWithSeen(alert) + .map(aws ⇒ Json.obj("artifacts" → aws)) + else Future.successful(JsObject.empty) } yield { - renderer.toOutput(OK, alertsWithStats ++ similarCases) + renderer.toOutput(OK, alertsWithStats ++ similarCases ++ similarArtifacts) } } diff --git a/thehive-backend/app/controllers/AttachmentCtrl.scala b/thehive-backend/app/controllers/AttachmentCtrl.scala index 9d61cbf23e..cda2d41dd2 100644 --- a/thehive-backend/app/controllers/AttachmentCtrl.scala +++ b/thehive-backend/app/controllers/AttachmentCtrl.scala @@ -21,8 +21,8 @@ import org.elastic4play.models.AttachmentAttributeFormat import org.elastic4play.services.AttachmentSrv /** - * Controller used to access stored attachments (plain or zipped) - */ + * Controller used to access stored attachments (plain or zipped) + */ @Singleton class AttachmentCtrl( password: String, @@ -33,12 +33,12 @@ class AttachmentCtrl( renderer: Renderer) extends AbstractController(components) { @Inject() def this( - configuration: Configuration, - tempFileCreator: DefaultTemporaryFileCreator, - attachmentSrv: AttachmentSrv, - authenticated: Authenticated, - components: ControllerComponents, - renderer: Renderer) = + configuration: Configuration, + tempFileCreator: DefaultTemporaryFileCreator, + attachmentSrv: AttachmentSrv, + authenticated: Authenticated, + components: ControllerComponents, + renderer: Renderer) = this( configuration.get[String]("datastore.attachment.password"), tempFileCreator, @@ -48,10 +48,10 @@ class AttachmentCtrl( renderer) /** - * Download an attachment, identified by its hash, in plain format - * File name can be specified. This method is not protected : browser will - * open the document directly. It must be used only for safe file - */ + * Download an attachment, identified by its hash, in plain format + * File name can be specified. This method is not protected : browser will + * open the document directly. It must be used only for safe file + */ @Timed("controllers.AttachmentCtrl.download") def download(hash: String, name: Option[String]): Action[AnyContent] = authenticated(Roles.read) { implicit request ⇒ if (hash.startsWith("{{")) // angularjs hack @@ -65,14 +65,14 @@ class AttachmentCtrl( Map( "Content-Disposition" → s"""attachment; filename="${URLEncoder.encode(name.getOrElse(hash), "utf-8")}"""", "Content-Transfer-Encoding" → "binary")), - body = HttpEntity.Streamed(attachmentSrv.source(hash), None, None)) + body = HttpEntity.Streamed(attachmentSrv.source(hash), None, None)) } /** - * Download an attachment, identified by its hash, in zip format. - * Zip file is protected by the password "malware" - * File name can be specified (zip extension is append) - */ + * Download an attachment, identified by its hash, in zip format. + * Zip file is protected by the password "malware" + * File name can be specified (zip extension is append) + */ @Timed("controllers.AttachmentCtrl.downloadZip") def downloadZip(hash: String, name: Option[String]): Action[AnyContent] = authenticated(Roles.read) { implicit request ⇒ if (!name.getOrElse("").intersect(AttachmentAttributeFormat.forbiddenChar).isEmpty) @@ -98,7 +98,7 @@ class AttachmentCtrl( "Content-Type" → "application/zip", "Content-Transfer-Encoding" → "binary", "Content-Length" → Files.size(f).toString)), - body = HttpEntity.Streamed(FileIO.fromPath(f), Some(Files.size(f)), Some("application/zip"))) + body = HttpEntity.Streamed(FileIO.fromPath(f), Some(Files.size(f)), Some("application/zip"))) } } } \ No newline at end of file diff --git a/thehive-backend/app/controllers/DashboardCtrl.scala b/thehive-backend/app/controllers/DashboardCtrl.scala new file mode 100644 index 0000000000..5a1f1da796 --- /dev/null +++ b/thehive-backend/app/controllers/DashboardCtrl.scala @@ -0,0 +1,87 @@ +package controllers + +import javax.inject.{ Inject, Singleton } + +import scala.concurrent.{ ExecutionContext, Future } + +import play.api.Logger +import play.api.http.Status +import play.api.mvc._ + +import akka.stream.Materializer +import models.Roles +import services.DashboardSrv + +import org.elastic4play.controllers.{ Authenticated, Fields, FieldsBodyParser, Renderer } +import org.elastic4play.models.JsonFormat.baseModelEntityWrites +import org.elastic4play.services.JsonFormat.{ aggReads, queryReads } +import org.elastic4play.services._ +import org.elastic4play.{ AuthorizationError, BadRequestError, Timed } + +@Singleton +class DashboardCtrl @Inject() ( + dashboardSrv: DashboardSrv, + auxSrv: AuxSrv, + authenticated: Authenticated, + renderer: Renderer, + migration: MigrationOperations, + components: ControllerComponents, + fieldsBodyParser: FieldsBodyParser, + implicit val ec: ExecutionContext, + implicit val mat: Materializer) extends AbstractController(components) with Status { + + private[DashboardCtrl] lazy val logger = Logger(getClass) + + @Timed + def create(): Action[Fields] = authenticated(Roles.write).async(fieldsBodyParser) { implicit request ⇒ + dashboardSrv.create(request.body) + .map(dashboard ⇒ renderer.toOutput(CREATED, dashboard)) + } + + @Timed + def get(id: String): Action[AnyContent] = authenticated(Roles.read).async { implicit request ⇒ + dashboardSrv.get(id).map { dashboard ⇒ + renderer.toOutput(OK, dashboard) + } + } + + @Timed + def update(id: String): Action[Fields] = authenticated(Roles.write).async(fieldsBodyParser) { implicit request ⇒ + for { + dashboard ← dashboardSrv.get(id) + updatedDashboard ← if (dashboard.createdBy == request.userId || request.roles.contains(Roles.admin)) + dashboardSrv.update(dashboard, request.body) + else + Future.failed(AuthorizationError("You can't update this dashboard, you are not the owner")) + } yield renderer.toOutput(OK, updatedDashboard) + } + + @Timed + def delete(id: String): Action[AnyContent] = authenticated(Roles.write).async { implicit request ⇒ + for { + dashboard ← dashboardSrv.get(id) + updatedDashboard ← if (dashboard.createdBy == request.userId || request.roles.contains(Roles.admin)) + dashboardSrv.delete(id) + else + Future.failed(AuthorizationError("You can't update this dashboard, you are not the owner")) + } yield NoContent + } + + @Timed + def find(): Action[Fields] = authenticated(Roles.read).async(fieldsBodyParser) { implicit request ⇒ + import org.elastic4play.services.QueryDSL._ + val query = request.body.getValue("query").fold[QueryDef]("status" ~!= "Deleted")(_.as[QueryDef]) + val range = request.body.getString("range") + val sort = request.body.getStrings("sort").getOrElse(Nil) + + val (dashboards, total) = dashboardSrv.find(query, range, sort) + renderer.toOutput(OK, dashboards, total) + } + + @Timed + def stats(): Action[Fields] = authenticated(Roles.read).async(fieldsBodyParser) { implicit request ⇒ + val query = request.body.getValue("query").fold[QueryDef](QueryDSL.any)(_.as[QueryDef]) + val aggs = request.body.getValue("stats").getOrElse(throw BadRequestError("Parameter \"stats\" is missing")).as[Seq[Agg]] + dashboardSrv.stats(query, aggs).map(s ⇒ Ok(s)) + } +} \ No newline at end of file diff --git a/thehive-backend/app/controllers/DescribeCtrl.scala b/thehive-backend/app/controllers/DescribeCtrl.scala new file mode 100644 index 0000000000..17301a641c --- /dev/null +++ b/thehive-backend/app/controllers/DescribeCtrl.scala @@ -0,0 +1,51 @@ +package controllers + +import javax.inject.{ Inject, Singleton } + +import scala.concurrent.ExecutionContext + +import play.api.libs.json.{ JsObject, Json } +import play.api.mvc.{ AbstractController, Action, AnyContent, ControllerComponents } + +import models.Roles + +import org.elastic4play.controllers.{ Authenticated, Renderer } +import org.elastic4play.models.{ Attribute, AttributeDefinition, BaseModelDef } +import org.elastic4play.models.JsonFormat.attributeDefinitionWrites +import org.elastic4play.services.{ DBLists, ModelSrv } + +@Singleton +class DescribeCtrl @Inject() ( + dblists: DBLists, + modelSrv: ModelSrv, + authenticated: Authenticated, + renderer: Renderer, + components: ControllerComponents, + implicit val ec: ExecutionContext) extends AbstractController(components) { + + private def modelToJson(model: BaseModelDef): JsObject = { + val attributeDefinitions = model.attributes.flatMap { + case attribute: Attribute[t] ⇒ attribute.format.definition(dblists, attribute) + } ++ model.computedMetrics.keys.map { computedMetricName ⇒ + AttributeDefinition(s"computed.$computedMetricName", "number", s"Computed metric $computedMetricName", Nil, Nil) + } + Json.obj( + "label" → model.label, + "path" → model.path, + "attributes" → attributeDefinitions) + } + def describe(modelName: String): Action[AnyContent] = authenticated(Roles.read) { implicit request ⇒ + modelSrv(modelName) + .map { model ⇒ renderer.toOutput(OK, modelToJson(model)) } + .getOrElse(NotFound(s"Model $modelName not found")) + } + + private val allModels: Seq[String] = Seq("case", "case_artifact", "case_task", "alert", "case_artifact_job") + def describeAll: Action[AnyContent] = authenticated(Roles.read) { implicit request ⇒ + val entityDefinitions = modelSrv.list + .collect { + case model if allModels.contains(model.modelName) ⇒ model.modelName → modelToJson(model) + } + renderer.toOutput(OK, JsObject(entityDefinitions)) + } +} diff --git a/thehive-backend/app/controllers/FlowCtrl.scala b/thehive-backend/app/controllers/FlowCtrl.scala index 315ce1f452..dfef5869ba 100644 --- a/thehive-backend/app/controllers/FlowCtrl.scala +++ b/thehive-backend/app/controllers/FlowCtrl.scala @@ -24,8 +24,8 @@ class FlowCtrl @Inject() ( implicit val ec: ExecutionContext) extends AbstractController(components) with Status { /** - * Return audit logs. For each item, include ancestor entities - */ + * Return audit logs. For each item, include ancestor entities + */ @Timed def flow(rootId: Option[String], count: Option[Int]): Action[AnyContent] = authenticated(Roles.read).async { implicit request ⇒ val (audits, total) = flowSrv(rootId.filterNot(_ == "any"), count.getOrElse(10)) diff --git a/thehive-backend/app/controllers/SearchCtrl.scala b/thehive-backend/app/controllers/SearchCtrl.scala index 96322b23e6..01e8e656bc 100644 --- a/thehive-backend/app/controllers/SearchCtrl.scala +++ b/thehive-backend/app/controllers/SearchCtrl.scala @@ -2,22 +2,24 @@ package controllers import javax.inject.{ Inject, Singleton } -import scala.concurrent.ExecutionContext +import scala.concurrent.{ ExecutionContext, Future } import play.api.http.Status +import play.api.libs.json.{ JsObject, Json } import play.api.mvc.{ AbstractController, Action, ControllerComponents } import models.Roles -import org.elastic4play.Timed +import org.elastic4play.{ BadRequestError, Timed } import org.elastic4play.controllers.{ Authenticated, Fields, FieldsBodyParser, Renderer } -import org.elastic4play.services.JsonFormat.queryReads +import org.elastic4play.services.JsonFormat.{ aggReads, queryReads } import org.elastic4play.services._ @Singleton class SearchCtrl @Inject() ( findSrv: FindSrv, auxSrv: AuxSrv, + modelSrv: ModelSrv, authenticated: Authenticated, renderer: Renderer, components: ControllerComponents, @@ -37,4 +39,24 @@ class SearchCtrl @Inject() ( val entitiesWithStats = auxSrv(entities, nparent, withStats, removeUnaudited = true) renderer.toOutput(OK, entitiesWithStats, total) } + + @Timed + def stats(): Action[Fields] = authenticated(Roles.read).async(fieldsBodyParser) { implicit request ⇒ + import org.elastic4play.services.QueryDSL._ + val globalQuery = request.body.getValue("query").flatMap(_.asOpt[QueryDef]).toList + Future + .traverse(request.body.getValue("stats") + .getOrElse(throw BadRequestError("Parameter \"stats\" is missing")) + .as[Seq[JsObject]]) { statsJson ⇒ + + val query = (statsJson \ "query").asOpt[QueryDef].toList + val agg = (statsJson \ "stats").getOrElse(throw BadRequestError("Parameter \"stats\" is missing")).as[Seq[Agg]] + val modelName = (statsJson \ "model").getOrElse(throw BadRequestError("Parameter \"model\" is missing")).as[String] + val model = modelSrv.apply(modelName).getOrElse(throw BadRequestError(s"Model $modelName doesn't exist")) + findSrv.apply(model, and(globalQuery ::: query), agg: _*) + } + .map { statsResults ⇒ + renderer.toOutput(OK, statsResults.reduceOption(_ deepMerge _).getOrElse(Json.obj())) + } + } } \ No newline at end of file diff --git a/thehive-backend/app/controllers/StatusCtrl.scala b/thehive-backend/app/controllers/StatusCtrl.scala index a5cbbc8ab8..59bb1a3b1c 100644 --- a/thehive-backend/app/controllers/StatusCtrl.scala +++ b/thehive-backend/app/controllers/StatusCtrl.scala @@ -3,16 +3,20 @@ package controllers import javax.inject.{ Inject, Singleton } import scala.collection.immutable +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.Try import play.api.Configuration import play.api.libs.json.{ JsObject, JsString, Json } import play.api.libs.json.Json.toJsFieldJsValueWrapper -import play.api.mvc.{ AbstractController, ControllerComponents } +import play.api.mvc.{ AbstractController, Action, AnyContent, ControllerComponents } import com.sksamuel.elastic4s.ElasticDsl import connectors.Connector +import models.HealthStatus import org.elastic4play.Timed +import org.elastic4play.database.DBIndex import org.elastic4play.services.AuthSrv import org.elastic4play.services.auth.MultiAuthSrv @@ -20,27 +24,53 @@ import org.elastic4play.services.auth.MultiAuthSrv class StatusCtrl @Inject() ( connectors: immutable.Set[Connector], configuration: Configuration, + dbIndex: DBIndex, authSrv: AuthSrv, - components: ControllerComponents) extends AbstractController(components) { + components: ControllerComponents, + implicit val ec: ExecutionContext) extends AbstractController(components) { private[controllers] def getVersion(c: Class[_]) = Option(c.getPackage.getImplementationVersion).getOrElse("SNAPSHOT") @Timed("controllers.StatusCtrl.get") - def get = Action { - Ok(Json.obj( - "versions" → Json.obj( - "TheHive" → getVersion(classOf[models.Case]), - "Elastic4Play" → getVersion(classOf[Timed]), - "Play" → getVersion(classOf[AbstractController]), - "Elastic4s" → getVersion(classOf[ElasticDsl]), - "ElasticSearch" → getVersion(classOf[org.elasticsearch.Build])), - "connectors" → JsObject(connectors.map(c ⇒ c.name → c.status).toSeq), - "config" → Json.obj( - "protectDownloadsWith" → configuration.get[String]("datastore.attachment.password"), - "authType" → (authSrv match { - case multiAuthSrv: MultiAuthSrv ⇒ multiAuthSrv.authProviders.map { a ⇒ JsString(a.name) } - case _ ⇒ JsString(authSrv.name) - }), - "capabilities" → authSrv.capabilities.map(c ⇒ JsString(c.toString))))) + def get: Action[AnyContent] = Action.async { + val clusterStatusName = Try(dbIndex.clusterStatusName).getOrElse("ERROR") + Future.traverse(connectors)(c ⇒ c.status.map(c.name → _)) + .map { connectorStatus ⇒ + Ok(Json.obj( + "versions" → Json.obj( + "TheHive" → getVersion(classOf[models.Case]), + "Elastic4Play" → getVersion(classOf[Timed]), + "Play" → getVersion(classOf[AbstractController]), + "Elastic4s" → getVersion(classOf[ElasticDsl]), + "ElasticSearch" → getVersion(classOf[org.elasticsearch.Build])), + "connectors" → JsObject(connectorStatus.toSeq), + "health" → Json.obj("elasticsearch" → clusterStatusName), + "config" → Json.obj( + "protectDownloadsWith" → configuration.get[String]("datastore.attachment.password"), + "authType" → (authSrv match { + case multiAuthSrv: MultiAuthSrv ⇒ multiAuthSrv.authProviders.map { a ⇒ JsString(a.name) } + case _ ⇒ JsString(authSrv.name) + }), + "capabilities" → authSrv.capabilities.map(c ⇒ JsString(c.toString))))) + } } -} + + @Timed("controllers.StatusCtrl.health") + def health: Action[AnyContent] = Action.async { + for { + dbStatusInt ← dbIndex.getClusterStatus + dbStatus = dbStatusInt match { + case 0 ⇒ HealthStatus.Ok + case 1 ⇒ HealthStatus.Warning + case _ ⇒ HealthStatus.Error + } + connectorStatus ← Future.traverse(connectors)(c ⇒ c.health) + distinctStatus = connectorStatus + dbStatus + globalStatus = if (distinctStatus.contains(HealthStatus.Ok)) { + if (distinctStatus.size > 1) HealthStatus.Warning else HealthStatus.Ok + } + else if (distinctStatus.contains(HealthStatus.Error)) HealthStatus.Error + else HealthStatus.Warning + } yield Ok(globalStatus.toString) + } +} \ No newline at end of file diff --git a/thehive-backend/app/controllers/StreamCtrl.scala b/thehive-backend/app/controllers/StreamCtrl.scala index 28a19c7695..67035fb9a7 100644 --- a/thehive-backend/app/controllers/StreamCtrl.scala +++ b/thehive-backend/app/controllers/StreamCtrl.scala @@ -40,15 +40,15 @@ class StreamCtrl( implicit val ec: ExecutionContext) extends AbstractController(components) with Status { @Inject() def this( - configuration: Configuration, - authenticated: Authenticated, - renderer: Renderer, - eventSrv: EventSrv, - auxSrv: AuxSrv, - migrationSrv: MigrationSrv, - components: ControllerComponents, - system: ActorSystem, - ec: ExecutionContext) = + configuration: Configuration, + authenticated: Authenticated, + renderer: Renderer, + eventSrv: EventSrv, + auxSrv: AuxSrv, + migrationSrv: MigrationSrv, + components: ControllerComponents, + system: ActorSystem, + ec: ExecutionContext) = this( configuration.getMillis("stream.longpolling.cache").millis, configuration.getMillis("stream.longpolling.refresh").millis, @@ -65,8 +65,8 @@ class StreamCtrl( private[StreamCtrl] lazy val logger = Logger(getClass) /** - * Create a new stream entry with the event head - */ + * Create a new stream entry with the event head + */ @Timed("controllers.StreamCtrl.create") def create: Action[AnyContent] = authenticated(Roles.read) { val id = generateStreamId() @@ -88,9 +88,9 @@ class StreamCtrl( } /** - * Get events linked to the identified stream entry - * This call waits up to "refresh", if there is no event, return empty response - */ + * Get events linked to the identified stream entry + * This call waits up to "refresh", if there is no event, return empty response + */ @Timed("controllers.StreamCtrl.get") def get(id: String): Action[AnyContent] = Action.async { implicit request ⇒ implicit val timeout: Timeout = Timeout(refresh + globalMaxWait + 1.second) diff --git a/thehive-backend/app/controllers/TaskCtrl.scala b/thehive-backend/app/controllers/TaskCtrl.scala index ddadd22ffe..f6d5fed33a 100644 --- a/thehive-backend/app/controllers/TaskCtrl.scala +++ b/thehive-backend/app/controllers/TaskCtrl.scala @@ -54,7 +54,7 @@ class TaskCtrl @Inject() ( def findInCase(caseId: String): Action[Fields] = authenticated(Roles.read).async(fieldsBodyParser) { implicit request ⇒ import org.elastic4play.services.QueryDSL._ val childQuery = request.body.getValue("query").fold[QueryDef](QueryDSL.any)(_.as[QueryDef]) - val query = and(childQuery, "_parent" ~= caseId) + val query = and(childQuery, withParent("case", caseId)) val range = request.body.getString("range") val sort = request.body.getStrings("sort").getOrElse(Nil) diff --git a/thehive-backend/app/controllers/UserCtrl.scala b/thehive-backend/app/controllers/UserCtrl.scala index d7922402c9..8fea2671d3 100644 --- a/thehive-backend/app/controllers/UserCtrl.scala +++ b/thehive-backend/app/controllers/UserCtrl.scala @@ -95,7 +95,7 @@ class UserCtrl @Inject() ( .recover { case _ ⇒ logger.warn(s"User ${authContext.userId} has invalid preference format: ${user.preferences()}") - JsObject(Nil) + JsObject.empty } .get json = user.toJson + ("preferences" → preferences) diff --git a/thehive-backend/app/global/Filters.scala b/thehive-backend/app/global/Filters.scala index b319722e37..0346222741 100644 --- a/thehive-backend/app/global/Filters.scala +++ b/thehive-backend/app/global/Filters.scala @@ -34,14 +34,14 @@ object CSRFFilter { @Singleton class CSRFFilter @Inject() ( - config: Provider[CSRFConfig], - tokenSignerProvider: Provider[CSRFTokenSigner], - sessionConfiguration: SessionConfiguration, - tokenProvider: TokenProvider, - errorHandler: ErrorHandler)(mat: Materializer) - extends play.filters.csrf.CSRFFilter( - config.get.copy(shouldProtect = CSRFFilter.shouldProtect), - tokenSignerProvider.get, - sessionConfiguration, - tokenProvider, - errorHandler)(mat) \ No newline at end of file + config: Provider[CSRFConfig], + tokenSignerProvider: Provider[CSRFTokenSigner], + sessionConfiguration: SessionConfiguration, + tokenProvider: TokenProvider, + errorHandler: ErrorHandler)(mat: Materializer) + extends play.filters.csrf.CSRFFilter( + config.get.copy(shouldProtect = CSRFFilter.shouldProtect), + tokenSignerProvider.get, + sessionConfiguration, + tokenProvider, + errorHandler)(mat) \ No newline at end of file diff --git a/thehive-backend/app/models/Alert.scala b/thehive-backend/app/models/Alert.scala index e2da83e9d4..b1efe5880e 100644 --- a/thehive-backend/app/models/Alert.scala +++ b/thehive-backend/app/models/Alert.scala @@ -41,7 +41,7 @@ trait AlertAttributes { 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", "tlp", OptionalAttributeFormat(TlpAttributeFormat), Nil, None, ""), Attribute("alert", "tags", MultiAttributeFormat(F.stringFmt), Nil, None, ""), Attribute("alert", "ioc", OptionalAttributeFormat(F.booleanFmt), Nil, None, "")) } @@ -55,9 +55,9 @@ trait AlertAttributes { val caze: A[Option[String]] = optionalAttribute("case", F.stringFmt, "Id of the case, if created") val title: A[String] = attribute("title", F.textFmt, "Title of the alert") val description: A[String] = attribute("description", F.textFmt, "Description of the alert") - val severity: A[Long] = attribute("severity", F.numberFmt, "Severity if the alert (0-3)", 2L) + val severity: A[Long] = attribute("severity", SeverityAttributeFormat, "Severity if the alert (0-3)", 2L) val tags: A[Seq[String]] = multiAttribute("tags", F.stringFmt, "Alert tags") - val tlp: A[Long] = attribute("tlp", F.numberFmt, "TLP level", 2L) + val tlp: A[Long] = attribute("tlp", TlpAttributeFormat, "TLP level", 2L) val artifacts: A[Seq[JsObject]] = multiAttribute("artifacts", F.objectFmt(artifactAttributes), "Artifact of the alert") val caseTemplate: A[Option[String]] = optionalAttribute("caseTemplate", F.stringFmt, "Case template to use") val status: A[AlertStatus.Value] = attribute("status", F.enumFmt(AlertStatus), "Status of the alert", AlertStatus.New) @@ -66,13 +66,15 @@ trait AlertAttributes { @Singleton class AlertModel @Inject() (dblists: DBLists) - extends ModelDef[AlertModel, Alert]("alert") - with AlertAttributes - with AuditedModel { + extends ModelDef[AlertModel, Alert]("alert", "Alert", "/alert") + with AlertAttributes + with AuditedModel { private[AlertModel] lazy val logger = Logger(getClass) override val defaultSortBy: Seq[String] = Seq("-date") override val removeAttribute: JsObject = Json.obj("status" → AlertStatus.Ignored) + override val computedMetrics: Map[String, String] = Map( + "observableCount" → "_source['artifacts']?.size()") override def creationHook(parent: Option[BaseEntity], attrs: JsObject): Future[JsObject] = { // check if data attribute is present on all artifacts @@ -103,8 +105,8 @@ class AlertModel @Inject() (dblists: DBLists) } class Alert(model: AlertModel, attributes: JsObject) - extends EntityDef[AlertModel, Alert](model, attributes) - with AlertAttributes { + extends EntityDef[AlertModel, Alert](model, attributes) + with AlertAttributes { override def toJson: JsObject = super.toJson + ("artifacts" → JsArray(artifacts().map { diff --git a/thehive-backend/app/models/Artifact.scala b/thehive-backend/app/models/Artifact.scala index 401c83542b..a30ace3e36 100644 --- a/thehive-backend/app/models/Artifact.scala +++ b/thehive-backend/app/models/Artifact.scala @@ -35,9 +35,10 @@ trait ArtifactAttributes { _: AttributeDef ⇒ 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) + val tlp: A[Long] = attribute("tlp", TlpAttributeFormat, "TLP level", 2L) val tags: A[Seq[String]] = multiAttribute("tags", F.stringFmt, "Artifact tags") val ioc: A[Boolean] = attribute("ioc", F.booleanFmt, "Artifact is an IOC", false) + val sighted: A[Boolean] = attribute("sighted", F.booleanFmt, "Artifact has been sighted on the local network", false) val status: A[ArtifactStatus.Value] = attribute("status", F.enumFmt(ArtifactStatus), "Status of the artifact", ArtifactStatus.Ok) val reports: A[String] = attribute("reports", F.textFmt, "Json object that contains all short reports", "{}", O.unaudited) } @@ -49,7 +50,7 @@ class ArtifactModel @Inject() ( 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 { + implicit val ec: ExecutionContext) extends ChildModelDef[ArtifactModel, Artifact, CaseModel, Case](caseModel, "case_artifact", "Observable", "/case/artifact") with ArtifactAttributes with AuditedModel { private[ArtifactModel] lazy val logger = Logger(getClass) override val removeAttribute: JsObject = Json.obj("status" → ArtifactStatus.Deleted) @@ -116,7 +117,7 @@ class ArtifactModel @Inject() ( val (_, total) = artifactSrv.get.findSimilar(artifact, Some("0-0"), Nil) total.failed.foreach(t ⇒ logger.error("Artifact.getStats error", t)) total.map { t ⇒ Json.obj("seen" → t) } - case _ ⇒ Future.successful(JsObject(Nil)) + case _ ⇒ Future.successful(JsObject.empty) } } } diff --git a/thehive-backend/app/models/AttributeFormat.scala b/thehive-backend/app/models/AttributeFormat.scala new file mode 100644 index 0000000000..fd98a30140 --- /dev/null +++ b/thehive-backend/app/models/AttributeFormat.scala @@ -0,0 +1,80 @@ +package models + +import play.api.libs.json.{ JsNumber, JsValue } + +import org.scalactic.{ Every, Good, One, Or } + +import org.elastic4play.controllers.{ InputValue, JsonInputValue, StringInputValue } +import org.elastic4play.models.{ Attribute, AttributeDefinition, NumberAttributeFormat } +import org.elastic4play.services.DBLists +import org.elastic4play.{ AttributeError, InvalidFormatAttributeError } + +object SeverityAttributeFormat extends NumberAttributeFormat { + + def isValidValue(value: Long): Boolean = 1 <= value && value <= 3 + + override def definition(dblists: DBLists, attribute: Attribute[Long]): Seq[AttributeDefinition] = + Seq(AttributeDefinition( + attribute.attributeName, + name, + attribute.description, + Seq(JsNumber(1), JsNumber(2), JsNumber(3)), + Seq("low", "medium", "high"))) + + override def checkJson(subNames: Seq[String], value: JsValue): Or[JsValue, One[InvalidFormatAttributeError]] = { + value match { + case JsNumber(v) if subNames.isEmpty && isValidValue(v.toLong) ⇒ Good(value) + case _ ⇒ formatError(JsonInputValue(value)) + } + } + + override def fromInputValue(subNames: Seq[String], value: InputValue): Long Or Every[AttributeError] = { + value match { + case StringInputValue(Seq(v)) if subNames.isEmpty ⇒ + try { + val longValue = v.toLong + if (isValidValue(longValue)) Good(longValue) + else formatError(value) + } + catch { + case _: Throwable ⇒ formatError(value) + } + case JsonInputValue(JsNumber(v)) ⇒ Good(v.longValue) + case _ ⇒ formatError(value) + } + } +} + +object TlpAttributeFormat extends NumberAttributeFormat { + + def isValidValue(value: Long): Boolean = 0 <= value && value <= 3 + + override def definition(dblists: DBLists, attribute: Attribute[Long]): Seq[AttributeDefinition] = + Seq(AttributeDefinition( + attribute.attributeName, + name, + attribute.description, + Seq(JsNumber(0), JsNumber(1), JsNumber(2), JsNumber(3)), + Seq("white", "green", "amber", "red"))) + + override def checkJson(subNames: Seq[String], value: JsValue): Or[JsValue, One[InvalidFormatAttributeError]] = value match { + case JsNumber(v) if subNames.isEmpty && isValidValue(v.toLong) ⇒ Good(value) + case _ ⇒ formatError(JsonInputValue(value)) + } + + override def fromInputValue(subNames: Seq[String], value: InputValue): Long Or Every[AttributeError] = { + value match { + case StringInputValue(Seq(v)) if subNames.isEmpty ⇒ + try { + val longValue = v.toLong + if (isValidValue(longValue)) Good(longValue) + else formatError(value) + } + catch { + case _: Throwable ⇒ formatError(value) + } + case JsonInputValue(JsNumber(v)) ⇒ Good(v.longValue) + case _ ⇒ formatError(value) + } + } +} \ No newline at end of file diff --git a/thehive-backend/app/models/Audit.scala b/thehive-backend/app/models/Audit.scala index 5c9d1c8860..370ad8e245 100644 --- a/thehive-backend/app/models/Audit.scala +++ b/thehive-backend/app/models/Audit.scala @@ -18,7 +18,7 @@ trait AuditAttributes { _: AttributeDef ⇒ def detailsAttributes: Seq[Attribute[_]] 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 details: A[JsObject] = attribute("details", AttributeFormat.objectFmt(detailsAttributes), "Details", JsObject.empty, 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) @@ -31,11 +31,11 @@ trait AuditAttributes { _: AttributeDef ⇒ @Singleton class AuditModel( auditName: String, - auditedModels: immutable.Set[AuditedModel]) extends ModelDef[AuditModel, Audit](auditName) with AuditAttributes { + auditedModels: immutable.Set[AuditedModel]) extends ModelDef[AuditModel, Audit](auditName, "Audit", "/audit") with AuditAttributes { @Inject() def this( - configuration: Configuration, - auditedModels: immutable.Set[AuditedModel]) = + configuration: Configuration, + auditedModels: immutable.Set[AuditedModel]) = this( configuration.get[String]("audit.name"), auditedModels) @@ -60,7 +60,7 @@ class AuditModel( def mergeAttributes(context: String, attributes: Seq[Attribute[_]]): Option[ObjectAttributeFormat] = { val mergeAttributes: Iterable[Option[Attribute[_]]] = attributes - .groupBy(_.name) + .groupBy(_.attributeName) .map { case (_name, _attributes) ⇒ _attributes @@ -76,7 +76,7 @@ class AuditModel( } .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")}") + logger.error(s"Mapping is not consistent on attribute $context:\n${_attributes.map(a ⇒ a.modelName + "/" + a.attributeName + ": " + a.format.name).mkString("\n")}") None } } diff --git a/thehive-backend/app/models/Case.scala b/thehive-backend/app/models/Case.scala index 059bf97590..6780b6d1d7 100644 --- a/thehive-backend/app/models/Case.scala +++ b/thehive-backend/app/models/Case.scala @@ -37,21 +37,21 @@ trait CaseAttributes { _: AttributeDef ⇒ val caseId: A[Long] = attribute("caseId", F.numberFmt, "Id of the case (auto-generated)", O.model) val title: A[String] = attribute("title", F.textFmt, "Title of the case") val description: A[String] = attribute("description", F.textFmt, "Description of the case") - val severity: A[Long] = attribute("severity", F.numberFmt, "Severity if the case is an incident (0-3)", 2L) - val owner: A[String] = attribute("owner", F.stringFmt, "Owner of the case") + val severity: A[Long] = attribute("severity", SeverityAttributeFormat, "Severity if the case is an incident (0-3)", 2L) + val owner: A[String] = attribute("owner", F.userFmt, "Owner of the case") val startDate: A[Date] = attribute("startDate", F.dateFmt, "Creation date", new Date) val endDate: A[Option[Date]] = optionalAttribute("endDate", F.dateFmt, "Resolution date") val tags: A[Seq[String]] = multiAttribute("tags", F.stringFmt, "Case tags") 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 tlp: A[Long] = attribute("tlp", TlpAttributeFormat, "TLP level", 2L) val status: A[CaseStatus.Value] = attribute("status", F.enumFmt(CaseStatus), "Status of the case", CaseStatus.Open) - val metrics: A[JsValue] = attribute("metrics", F.metricsFmt, "List of metrics", JsObject(Nil)) + val metrics: A[JsValue] = attribute("metrics", F.metricsFmt, "List of metrics", JsObject.empty) 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)) + val customFields: A[JsValue] = attribute("customFields", F.customFields, "Custom fields", JsObject.empty) } @Singleton @@ -62,7 +62,7 @@ class CaseModel @Inject() ( alertModel: Provider[AlertModel], sequenceSrv: SequenceSrv, findSrv: FindSrv, - implicit val ec: ExecutionContext) extends ModelDef[CaseModel, Case]("case") with CaseAttributes with AuditedModel { caseModel ⇒ + implicit val ec: ExecutionContext) extends ModelDef[CaseModel, Case]("case", "Case", "/case") with CaseAttributes with AuditedModel { caseModel ⇒ private[CaseModel] lazy val logger = Logger(getClass) override val defaultSortBy = Seq("-startDate") @@ -79,7 +79,7 @@ class CaseModel @Inject() ( case Some(CaseStatus.Resolved) if !updateAttrs.keys.contains("endDate") ⇒ updateAttrs + ("endDate" → Json.toJson(new Date)) + - ("flag" → JsBoolean(false)) + ("flag" → JsFalse) case Some(CaseStatus.Open) ⇒ updateAttrs + ("endDate" → JsArray(Nil)) case _ ⇒ @@ -109,7 +109,7 @@ class CaseModel @Inject() ( "status" in ("Waiting", "InProgress", "Completed")), groupByField("status", selectCount)) .map { taskStatsJson ⇒ - val (taskCount, taskStats) = taskStatsJson.value.foldLeft((0L, JsObject(Nil))) { + val (taskCount, taskStats) = taskStatsJson.value.foldLeft((0L, JsObject.empty)) { case ((total, s), (key, value)) ⇒ val count = (value \ "count").as[Long] (total + count, s + (key → JsNumber(count))) @@ -177,7 +177,9 @@ class CaseModel @Inject() ( } override val computedMetrics = Map( - "handlingDuration" → "doc['endDate'].value - doc['startDate'].value") + "handlingDurationInSeconds" → "(doc['endDate'].value - doc['startDate'].value) / 1000", + "handlingDurationInHours" → "(doc['endDate'].value - doc['startDate'].value) / 3600000", + "handlingDurationInDays" → "(doc['endDate'].value - doc['startDate'].value) / (3600000 * 24)") } class Case(model: CaseModel, attributes: JsObject) extends EntityDef[CaseModel, Case](model, attributes) with CaseAttributes diff --git a/thehive-backend/app/models/CaseTemplate.scala b/thehive-backend/app/models/CaseTemplate.scala index f6775f64a9..5fe4af2ed7 100644 --- a/thehive-backend/app/models/CaseTemplate.scala +++ b/thehive-backend/app/models/CaseTemplate.scala @@ -19,18 +19,18 @@ trait CaseTemplateAttributes { _: AttributeDef ⇒ val templateName: A[String] = attribute("name", F.stringFmt, "Name of the template") val titlePrefix: A[Option[String]] = optionalAttribute("titlePrefix", F.textFmt, "Title of the case") val description: A[Option[String]] = optionalAttribute("description", F.textFmt, "Description of the case") - val severity: A[Option[Long]] = optionalAttribute("severity", F.numberFmt, "Severity if the case is an incident (0-5)") + val severity: A[Option[Long]] = optionalAttribute("severity", SeverityAttributeFormat, "Severity if the case is an incident (0-5)") val tags: A[Seq[String]] = multiAttribute("tags", F.stringFmt, "Case tags") val flag: A[Option[Boolean]] = optionalAttribute("flag", F.booleanFmt, "Flag of the case") - val tlp: A[Option[Long]] = optionalAttribute("tlp", F.numberFmt, "TLP level") + val tlp: A[Option[Long]] = optionalAttribute("tlp", TlpAttributeFormat, "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 metrics: A[JsValue] = attribute("metrics", F.metricsFmt, "List of acceptable metrics") 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 { +class CaseTemplateModel @Inject() (taskModel: TaskModel) extends ModelDef[CaseTemplateModel, CaseTemplate]("caseTemplate", "Case template", "/caseTemplate") with CaseTemplateAttributes { def taskAttributes: Seq[Attribute[_]] = taskModel .attributes .filter(_.isForm) diff --git a/thehive-backend/app/models/Dashboard.scala b/thehive-backend/app/models/Dashboard.scala new file mode 100644 index 0000000000..e370e91d83 --- /dev/null +++ b/thehive-backend/app/models/Dashboard.scala @@ -0,0 +1,32 @@ +package models + +import javax.inject.{ Inject, Singleton } + +import play.api.Logger +import play.api.libs.json.Json.toJsFieldJsValueWrapper +import play.api.libs.json._ + +import models.JsonFormat.dashboardStatusFormat + +import org.elastic4play.models.{ AttributeDef, EntityDef, HiveEnumeration, ModelDef, AttributeFormat ⇒ F } + +object DashboardStatus extends Enumeration with HiveEnumeration { + type Type = Value + val Private, Shared, Deleted = Value +} + +trait DashboardAttributes { _: AttributeDef ⇒ + val title: A[String] = attribute("title", F.textFmt, "Title of the dashboard") + val description: A[String] = attribute("description", F.textFmt, "Description of the dashboard") + val status: A[DashboardStatus.Value] = attribute("status", F.enumFmt(DashboardStatus), "Status of the case", DashboardStatus.Private) + val definition: A[String] = attribute("definition", F.textFmt, "Dashboard definition") +} + +@Singleton +class DashboardModel @Inject() () extends ModelDef[DashboardModel, Dashboard]("dashboard", "Dashboard", "/dashboard") with DashboardAttributes { dashboardModel ⇒ + + private[DashboardModel] lazy val logger = Logger(getClass) + override val removeAttribute: JsObject = Json.obj("status" → DashboardStatus.Deleted) +} + +class Dashboard(model: DashboardModel, attributes: JsObject) extends EntityDef[DashboardModel, Dashboard](model, attributes) with DashboardAttributes diff --git a/thehive-backend/app/models/HealthStatus.scala b/thehive-backend/app/models/HealthStatus.scala new file mode 100644 index 0000000000..c13f41fc26 --- /dev/null +++ b/thehive-backend/app/models/HealthStatus.scala @@ -0,0 +1,8 @@ +package models + +import org.elastic4play.models.HiveEnumeration + +object HealthStatus extends Enumeration with HiveEnumeration { + type Type = Value + val Ok, Warning, Error = Value +} \ No newline at end of file diff --git a/thehive-backend/app/models/JsonFormat.scala b/thehive-backend/app/models/JsonFormat.scala index 3f79ed3ef5..3fac83db9d 100644 --- a/thehive-backend/app/models/JsonFormat.scala +++ b/thehive-backend/app/models/JsonFormat.scala @@ -17,6 +17,7 @@ object JsonFormat { implicit val logStatusFormat: Format[LogStatus.Type] = enumFormat(LogStatus) implicit val caseTemplateStatusFormat: Format[CaseTemplateStatus.Type] = enumFormat(CaseTemplateStatus) implicit val alertStatusFormat: Format[AlertStatus.Type] = enumFormat(AlertStatus) + implicit val dashboardStatusFormat: Format[DashboardStatus.Type] = enumFormat(DashboardStatus) implicit val pathWrites: Writes[Path] = Writes((value: Path) ⇒ JsString(value.toString)) diff --git a/thehive-backend/app/models/Log.scala b/thehive-backend/app/models/Log.scala index cf07277b62..aa8685444e 100644 --- a/thehive-backend/app/models/Log.scala +++ b/thehive-backend/app/models/Log.scala @@ -27,10 +27,11 @@ trait LogAttributes { _: AttributeDef ⇒ // - contentType (string): the mimetype of the file (send by client) val attachment = optionalAttribute("attachment", F.attachmentFmt, "Attached file", O.readonly) val status = attribute("status", F.enumFmt(LogStatus), "Status of the log", LogStatus.Ok) + val owner = attribute("owner", F.userFmt, "User who owns the log") } @Singleton -class LogModel @Inject() (taskModel: TaskModel) extends ChildModelDef[LogModel, Log, TaskModel, Task](taskModel, "case_task_log") with LogAttributes with AuditedModel { +class LogModel @Inject() (taskModel: TaskModel) extends ChildModelDef[LogModel, Log, TaskModel, Task](taskModel, "case_task_log", "Log", "/case/task/log") with LogAttributes with AuditedModel { override val defaultSortBy = Seq("-startDate") override val removeAttribute = Json.obj("status" → LogStatus.Deleted) } diff --git a/thehive-backend/app/models/Migration.scala b/thehive-backend/app/models/Migration.scala index a3aa2af8ca..e189160d9f 100644 --- a/thehive-backend/app/models/Migration.scala +++ b/thehive-backend/app/models/Migration.scala @@ -1,9 +1,10 @@ package models +import java.nio.file.{ Files, Path, Paths } import java.util.Date import javax.inject.{ Inject, Singleton } -import scala.collection.immutable.{ Set ⇒ ISet } +import scala.collection.JavaConverters._ import scala.concurrent.{ ExecutionContext, Future } import scala.math.BigDecimal.int2bigDecimal import scala.util.Try @@ -15,13 +16,12 @@ import play.api.{ Configuration, Logger } import akka.NotUsed import akka.stream.Materializer import akka.stream.scaladsl.Source -import services.AlertSrv +import services.{ AlertSrv, DashboardSrv } -import org.elastic4play.models.BaseModelDef +import org.elastic4play.controllers.Fields import org.elastic4play.services.JsonFormat.attachmentFormat import org.elastic4play.services._ -import org.elastic4play.utils -import org.elastic4play.utils.{ Hasher, RichJson } +import org.elastic4play.utils.Hasher case class UpdateMispAlertArtifact() extends EventMessage @@ -31,25 +31,30 @@ class Migration( mainHash: String, extraHashes: Seq[String], datastoreName: String, - models: ISet[BaseModelDef], dblists: DBLists, eventSrv: EventSrv, + dashboardSrv: DashboardSrv, + userSrv: UserSrv, implicit val ec: ExecutionContext, implicit val materializer: Materializer) extends MigrationOperations { @Inject() def this( - configuration: Configuration, - models: ISet[BaseModelDef], - dblists: DBLists, - eventSrv: EventSrv, - ec: ExecutionContext, - materializer: Materializer) = { + configuration: Configuration, + dblists: DBLists, + eventSrv: EventSrv, + dashboardSrv: DashboardSrv, + userSrv: UserSrv, + ec: ExecutionContext, + materializer: Materializer) = { this( configuration.getOptional[String]("misp.caseTemplate"), configuration.get[String]("datastore.hash.main"), configuration.get[Seq[String]]("datastore.hash.extra"), configuration.get[String]("datastore.name"), - models, dblists, - eventSrv, ec, materializer) + dblists, + eventSrv, + dashboardSrv, + userSrv, + ec, materializer) } import org.elastic4play.services.Operation._ @@ -59,18 +64,53 @@ class Migration( override def beginMigration(version: Int): Future[Unit] = Future.successful(()) + private def readJsonFile(file: Path): JsValue = { + val source = scala.io.Source.fromFile(file.toFile) + try Json.parse(source.mkString) + finally source.close() + } + + private def addDataTypes(dataTypes: Seq[String]): Future[Unit] = { + val dataTypeList = dblists.apply("list_artifactDataType") + Future + .traverse(dataTypes) { dt ⇒ + dataTypeList.addItem(dt) + .map(_ ⇒ ()) + .recover { + case error ⇒ logger.error(s"Failed to add dataType $dt during migration", error) + } + } + .map(_ ⇒ ()) + } + + private def addDashboards(version: Int): Future[Unit] = { + userSrv.inInitAuthContext { implicit authContext ⇒ + val dashboardsPath = Paths.get("migration").resolve(version.toString).resolve("dashboards") + val dashboards = for { + dashboardFile ← Try(Files.newDirectoryStream(dashboardsPath, "*.json").asScala).getOrElse(Nil) + if Files.isReadable(dashboardFile) + dashboardJson ← Try(readJsonFile(dashboardFile).as[JsObject]).toOption + dashboardDefinition = (dashboardJson \ "definition").as[JsValue].toString + dash = dashboardSrv.create(Fields(dashboardJson + ("definition" -> JsString(dashboardDefinition)))) + .map(_ ⇒ ()) + .recover { + case error ⇒ logger.error(s"Failed to create dashboard $dashboardFile during migration", error) + } + } yield dash + Future.sequence(dashboards).map(_ ⇒ ()) + } + } + override def endMigration(version: Int): Future[Unit] = { if (requireUpdateMispAlertArtifact) { logger.info("Retrieve MISP attribute to update alerts") eventSrv.publish(UpdateMispAlertArtifact()) } logger.info("Updating observable data type list") - val dataTypes = dblists.apply("list_artifactDataType") - Future.sequence(Seq("filename", "fqdn", "url", "user-agent", "domain", "ip", "mail_subject", "hash", "mail", - "registry", "uri_path", "regexp", "other", "file") - .map(dt ⇒ dataTypes.addItem(dt).recover { case _ ⇒ () })) - .map(_ ⇒ ()) - .recover { case _ ⇒ () } + addDataTypes(Seq("filename", "fqdn", "url", "user-agent", "domain", "ip", "mail_subject", "hash", "mail", + "registry", "uri_path", "regexp", "other", "file", "autonomous-system")) + .andThen { case _ ⇒ addDashboards(version + 1) } + } override val operations: PartialFunction[DatabaseState, Seq[Operation]] = { @@ -123,7 +163,7 @@ class Migration( .getOrElse(2L) val source = (misp \ "serverId").asOpt[String].getOrElse("") val _id = hasher.fromString(s"misp|$source|$eventId").head.toString() - (misp \ "caze").asOpt[JsString].fold(JsObject(Nil))(c ⇒ Json.obj("caze" → c)) ++ + (misp \ "caze").asOpt[JsString].fold(JsObject.empty)(c ⇒ Json.obj("caze" → c)) ++ Json.obj( "_type" → "alert", "_id" → _id, @@ -233,20 +273,27 @@ class Migration( }, // 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)) + val metrics = (caze \ "metrics").asOpt[JsObject].getOrElse(JsObject.empty) + val customFields = (caze \ "customFields").asOpt[JsObject].getOrElse(JsObject.empty) caze + ("metrics" → metrics) + ("customFields" → customFields) }) case DatabaseState(10) ⇒ Nil + case DatabaseState(11) ⇒ + Seq( + mapEntity("case_task_log") { log ⇒ + val owner = (log \ "createdBy").asOpt[JsString].getOrElse(JsString("init")) + log + ("owner" → owner) + }, + mapEntity(_ ⇒ true, entity ⇒ entity - "user"), + mapEntity("caseTemplate") { caseTemplate ⇒ + val metricsName = (caseTemplate \ "metricNames").asOpt[Seq[String]].getOrElse(Nil) + val metrics = JsObject(metricsName.map(_ -> JsNull)) + caseTemplate - "metricNames" + ("metrics" -> metrics) + }, + addAttribute("case_artifact", "sighted" -> JsFalse)) } - private val requestCounter = new java.util.concurrent.atomic.AtomicInteger(0) - - def getRequestId: String = { - utils.Instance.id + ":mig:" + requestCounter.incrementAndGet() - } - - def convertDate(json: JsValue): JsValue = { + private def convertDate(json: JsValue): JsValue = { val datePattern = "yyyyMMdd'T'HHmmssZ" val dateReads = Reads.dateReads(datePattern).orElse(Reads.DefaultDateReads) val date = dateReads.reads(json).getOrElse { @@ -255,49 +302,4 @@ class Migration( } org.elastic4play.JsonFormat.dateFormat.writes(date) } - - def removeDot[A <: JsValue](json: A): A = json match { - case obj: JsObject ⇒ - obj.map { - case (key, value) if key.contains(".") ⇒ - val splittedKey = key.split("\\.") - splittedKey.head → splittedKey.tail.foldRight(removeDot(value))((k, v) ⇒ JsObject(Seq(k → v))) - case (key, value) ⇒ key → removeDot(value) - } - .asInstanceOf[A] - case other ⇒ other - } - - def auditDetailsCleanup(audit: JsObject): JsObject = removeDot { - // get audit details - (audit \ "details").asOpt[JsObject] - .flatMap { details ⇒ - // get object type of audited object - (audit \ "objectType") - .asOpt[String] - // find related model - .flatMap(objectType ⇒ models.find(_.name == objectType)) - // and get name of audited attributes - .map(_.attributes.collect { - case attr if !attr.isUnaudited ⇒ attr.name - }) - .map { attributes ⇒ - // put audited attribute in details and unaudited in otherDetails - val otherDetails = (audit \ "otherDetails") - .asOpt[String] - .flatMap(od ⇒ Try(Json.parse(od).as[JsObject]).toOption) - .getOrElse(JsObject(Nil)) - val (in, notIn) = details.fields.partition(f ⇒ attributes.contains(f._1.split("\\.").head)) - val newOtherDetails = otherDetails ++ JsObject(notIn) - audit + ("details" → JsObject(in)) + ("otherDetails" → JsString(newOtherDetails.toString.take(10000))) - } - } - .getOrElse(audit) - } - - def addAuditRequestId(audit: JsObject): JsObject = (audit \ "requestId").asOpt[String] match { - case None if (audit \ "base").toOption.isDefined ⇒ audit + ("requestId" → JsString(getRequestId)) - case None ⇒ audit + ("requestId" → JsString(getRequestId)) + ("base" → JsBoolean(true)) - case _ ⇒ audit - } } \ No newline at end of file diff --git a/thehive-backend/app/models/Task.scala b/thehive-backend/app/models/Task.scala index 0b99e25417..3dd1398de7 100644 --- a/thehive-backend/app/models/Task.scala +++ b/thehive-backend/app/models/Task.scala @@ -6,7 +6,7 @@ import javax.inject.{ Inject, Singleton } import scala.concurrent.Future import play.api.libs.json.JsValue.jsValueToJsLookup -import play.api.libs.json.{ JsBoolean, JsObject } +import play.api.libs.json.{ JsFalse, JsObject } import models.JsonFormat.taskStatusFormat import services.AuditedModel @@ -23,16 +23,17 @@ object TaskStatus extends Enumeration with HiveEnumeration { trait TaskAttributes { _: AttributeDef ⇒ val title = attribute("title", F.textFmt, "Title of the task") val description = optionalAttribute("description", F.textFmt, "Task details") - val owner = optionalAttribute("owner", F.stringFmt, "User who owns the task") + val owner = optionalAttribute("owner", F.userFmt, "User who owns the task") val status = attribute("status", F.enumFmt(TaskStatus), "Status of the task", TaskStatus.Waiting) val flag = attribute("flag", F.booleanFmt, "Flag of the task", false) val startDate = optionalAttribute("startDate", F.dateFmt, "Timestamp of the comment start") val endDate = optionalAttribute("endDate", F.dateFmt, "Timestamp of the comment end") val order = attribute("order", F.numberFmt, "Order of the task", 0L) - + val dueDate = optionalAttribute("dueDate", F.dateFmt, "When this date is passed, Thehive warns users") } + @Singleton -class TaskModel @Inject() (caseModel: CaseModel) extends ChildModelDef[TaskModel, Task, CaseModel, Case](caseModel, "case_task") with TaskAttributes with AuditedModel { +class TaskModel @Inject() (caseModel: CaseModel) extends ChildModelDef[TaskModel, Task, CaseModel, Case](caseModel, "case_task", "Task", "/case/task") with TaskAttributes with AuditedModel { override val defaultSortBy = Seq("-startDate") override def updateHook(task: BaseEntity, updateAttrs: JsObject): Future[JsObject] = Future.successful { @@ -43,7 +44,7 @@ class TaskModel @Inject() (caseModel: CaseModel) extends ChildModelDef[TaskModel case Some(TaskStatus.Completed) ⇒ updateAttrs .setIfAbsent("endDate", new Date) + - ("flag" → JsBoolean(false)) + ("flag" → JsFalse) case _ ⇒ updateAttrs } } diff --git a/thehive-backend/app/models/User.scala b/thehive-backend/app/models/User.scala index f092b0801e..741c9fe0e6 100644 --- a/thehive-backend/app/models/User.scala +++ b/thehive-backend/app/models/User.scala @@ -16,7 +16,7 @@ object UserStatus extends Enumeration with HiveEnumeration { } trait UserAttributes { _: AttributeDef ⇒ - val login = attribute("login", F.stringFmt, "Login of the user", O.form) + val login = attribute("login", F.userFmt, "Login of the user", O.form) val userId = attribute("_id", F.stringFmt, "User id (login)", O.model) val key = optionalAttribute("key", F.stringFmt, "API key", O.sensitive, O.unaudited) val userName = attribute("name", F.stringFmt, "Full name (Firstname Lastname)") @@ -27,7 +27,7 @@ trait UserAttributes { _: AttributeDef ⇒ val preferences = attribute("preferences", F.stringFmt, "User preferences", "{}", O.sensitive, O.unaudited) } -class UserModel extends ModelDef[UserModel, User]("user") with UserAttributes with AuditedModel { +class UserModel extends ModelDef[UserModel, User]("user", "User", "/user") with UserAttributes with AuditedModel { private def setUserId(attrs: JsObject) = (attrs \ "login").asOpt[JsString].fold(attrs) { login ⇒ attrs - "login" + ("_id" → login) diff --git a/thehive-backend/app/models/package.scala b/thehive-backend/app/models/package.scala index a7b96e9822..193c3d2d58 100644 --- a/thehive-backend/app/models/package.scala +++ b/thehive-backend/app/models/package.scala @@ -1,5 +1,5 @@ package object models { - val modelVersion = 11 + val modelVersion = 12 } \ No newline at end of file diff --git a/thehive-backend/app/services/AlertSrv.scala b/thehive-backend/app/services/AlertSrv.scala index 1cf512019e..0c508302c5 100644 --- a/thehive-backend/app/services/AlertSrv.scala +++ b/thehive-backend/app/services/AlertSrv.scala @@ -55,20 +55,20 @@ class AlertSrv( implicit val mat: Materializer) extends AlertTransformer { @Inject() def this( - configuration: Configuration, - alertModel: AlertModel, - createSrv: CreateSrv, - getSrv: GetSrv, - updateSrv: UpdateSrv, - deleteSrv: DeleteSrv, - findSrv: FindSrv, - caseSrv: CaseSrv, - artifactSrv: ArtifactSrv, - caseTemplateSrv: CaseTemplateSrv, - attachmentSrv: AttachmentSrv, - connectors: ConnectorRouter, - ec: ExecutionContext, - mat: Materializer) = this( + configuration: Configuration, + alertModel: AlertModel, + createSrv: CreateSrv, + getSrv: GetSrv, + updateSrv: UpdateSrv, + deleteSrv: DeleteSrv, + findSrv: FindSrv, + caseSrv: CaseSrv, + artifactSrv: ArtifactSrv, + caseTemplateSrv: CaseTemplateSrv, + attachmentSrv: AttachmentSrv, + connectors: ConnectorRouter, + ec: ExecutionContext, + mat: Materializer) = this( configuration.getOptional[Int]("maxSimilarCases").getOrElse(100), Map.empty[String, String], alertModel: AlertModel, @@ -181,7 +181,8 @@ class AlertSrv( .set("severity", JsNumber(alert.severity())) .set("tags", JsArray(alert.tags().map(JsString))) .set("tlp", JsNumber(alert.tlp())) - .set("status", CaseStatus.Open.toString), + .set("status", CaseStatus.Open.toString) + .set("startDate", Json.toJson(alert.date())), caseTemplate) _ ← importArtifacts(alert, caze) _ ← setCase(alert, caze) @@ -319,6 +320,24 @@ class AlertSrv( .runWith(Sink.seq) } + def getArtifactSeen(artifact: JsObject): Future[Long] = { + val maybeArtifactSeen = for { + dataType ← (artifact \ "dataType").asOpt[String] + data ← dataType match { + case "file" ⇒ (artifact \ "attachment").asOpt[Attachment].map(Right.apply) + case _ ⇒ (artifact \ "data").asOpt[String].map(Left.apply) + } + numberOfSimilarArtifacts = artifactSrv.findSimilar(dataType, data, None, None, Nil)._2 + } yield numberOfSimilarArtifacts + maybeArtifactSeen.getOrElse(Future.successful(0L)) + } + + def alertArtifactsWithSeen(alert: Alert): Future[Seq[JsObject]] = { + Future.traverse(alert.artifacts()) { artifact ⇒ + getArtifactSeen(artifact).map(seen ⇒ artifact + ("seen" → JsNumber(seen))) + } + } + def fixStatus()(implicit authContext: AuthContext): Future[Unit] = { import org.elastic4play.services.QueryDSL._ diff --git a/thehive-backend/app/services/AuditSrv.scala b/thehive-backend/app/services/AuditSrv.scala index 4603969a3c..e455d6bea4 100644 --- a/thehive-backend/app/services/AuditSrv.scala +++ b/thehive-backend/app/services/AuditSrv.scala @@ -20,7 +20,7 @@ trait AuditedModel { self: BaseModelDef ⇒ lazy val auditedAttributes: Map[String, Attribute[_]] = attributes - .collect { case a if !a.isUnaudited ⇒ a.name → a } + .collect { case a if !a.isUnaudited ⇒ a.attributeName → a } .toMap def selectAuditedAttributes(attrs: JsObject) = JsObject { attrs.fields.flatMap { @@ -66,7 +66,7 @@ class AuditActor @Inject() ( val audit = Json.obj( "operation" → action, "details" → model.selectAuditedAttributes(details), - "objectType" → model.name, + "objectType" → model.modelName, "objectId" → id, "base" → !currentRequestIds.contains(requestId), "startDate" → date, diff --git a/thehive-backend/app/services/CaseSrv.scala b/thehive-backend/app/services/CaseSrv.scala index e802d77cab..cb0e3af47f 100644 --- a/thehive-backend/app/services/CaseSrv.scala +++ b/thehive-backend/app/services/CaseSrv.scala @@ -32,17 +32,17 @@ class CaseSrv( implicit val ec: ExecutionContext) { @Inject() def this( - configuration: Configuration, - caseModel: CaseModel, - artifactModel: ArtifactModel, - taskModel: TaskModel, - createSrv: CreateSrv, - artifactSrv: ArtifactSrv, - getSrv: GetSrv, - updateSrv: UpdateSrv, - deleteSrv: DeleteSrv, - findSrv: FindSrv, - ec: ExecutionContext) = this( + configuration: Configuration, + caseModel: CaseModel, + artifactModel: ArtifactModel, + taskModel: TaskModel, + createSrv: CreateSrv, + artifactSrv: ArtifactSrv, + getSrv: GetSrv, + updateSrv: UpdateSrv, + deleteSrv: DeleteSrv, + findSrv: FindSrv, + ec: ExecutionContext) = this( configuration.getOptional[Int]("maxSimilarCases").getOrElse(100), caseModel, artifactModel, @@ -58,13 +58,12 @@ class CaseSrv( private[CaseSrv] lazy val logger = Logger(getClass) def applyTemplate(template: CaseTemplate, originalFields: Fields): Fields = { - def getJsObjectOrEmpty(value: Option[JsValue]) = value.fold(JsObject(Nil)) { + def getJsObjectOrEmpty(value: Option[JsValue]) = value.fold(JsObject.empty) { case obj: JsObject ⇒ obj - case _ ⇒ JsObject(Nil) + case _ ⇒ JsObject.empty } - val metricNames = (originalFields.getStrings("metricNames").getOrElse(Nil) ++ template.metricNames()).distinct - val metrics = JsObject(metricNames.map(_ → JsNull)) + val metrics = originalFields.getValue("metrics").fold(JsObject.empty)(_.as[JsObject]) deepMerge template.metrics().as[JsObject] val tags = (originalFields.getStrings("tags").getOrElse(Nil) ++ template.tags()).distinct val customFields = getJsObjectOrEmpty(template.customFields()) ++ getJsObjectOrEmpty(originalFields.getValue("customFields")) @@ -75,7 +74,7 @@ class CaseSrv( .set("tags", JsArray(tags.map(JsString))) .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("metrics", originalFields.getValue("metrics").flatMap(_.asOpt[JsObject]).getOrElse(JsObject.empty) ++ metrics) .set("customFields", customFields) } diff --git a/thehive-backend/app/services/DashboardSrv.scala b/thehive-backend/app/services/DashboardSrv.scala new file mode 100644 index 0000000000..03cc9b8989 --- /dev/null +++ b/thehive-backend/app/services/DashboardSrv.scala @@ -0,0 +1,50 @@ +package services + +import javax.inject.{ Inject, Singleton } + +import scala.concurrent.{ ExecutionContext, Future } + +import play.api.Logger +import play.api.libs.json._ + +import akka.NotUsed +import akka.stream.scaladsl.Source +import models._ + +import org.elastic4play.controllers.Fields +import org.elastic4play.services._ + +@Singleton +class DashboardSrv @Inject() ( + dashboardModel: DashboardModel, + createSrv: CreateSrv, + getSrv: GetSrv, + updateSrv: UpdateSrv, + deleteSrv: DeleteSrv, + findSrv: FindSrv, + implicit val ec: ExecutionContext) { + + private[DashboardSrv] lazy val logger = Logger(getClass) + + def create(fields: Fields)(implicit authContext: AuthContext): Future[Dashboard] = { + createSrv[DashboardModel, Dashboard](dashboardModel, fields) + } + + def get(id: String): Future[Dashboard] = + getSrv[DashboardModel, Dashboard](dashboardModel, id) + + def update(id: String, fields: Fields)(implicit authContext: AuthContext): Future[Dashboard] = + updateSrv[DashboardModel, Dashboard](dashboardModel, id, fields) + + def update(dashboard: Dashboard, fields: Fields)(implicit authContext: AuthContext): Future[Dashboard] = + updateSrv(dashboard, fields) + + def delete(id: String)(implicit Context: AuthContext): Future[Dashboard] = + deleteSrv[DashboardModel, Dashboard](dashboardModel, id) + + def find(queryDef: QueryDef, range: Option[String], sortBy: Seq[String]): (Source[Dashboard, NotUsed], Future[Long]) = { + findSrv[DashboardModel, Dashboard](dashboardModel, queryDef, range, sortBy) + } + + def stats(queryDef: QueryDef, aggs: Seq[Agg]): Future[JsObject] = findSrv(dashboardModel, queryDef, aggs: _*) +} diff --git a/thehive-backend/app/services/FlowSrv.scala b/thehive-backend/app/services/FlowSrv.scala index ed4eb7ed84..e5bc18d194 100644 --- a/thehive-backend/app/services/FlowSrv.scala +++ b/thehive-backend/app/services/FlowSrv.scala @@ -29,7 +29,7 @@ class FlowSrv @Inject() ( import org.elastic4play.services.QueryDSL._ val streamableEntities = modelSrv.list.collect { - case m: AuditedModel if m.name != "user" ⇒ m.name + case m: AuditedModel if m.modelName != "user" ⇒ m.modelName } val filter = rootId match { diff --git a/thehive-backend/app/services/LogSrv.scala b/thehive-backend/app/services/LogSrv.scala index 15a5c5636f..09cb2aee2f 100644 --- a/thehive-backend/app/services/LogSrv.scala +++ b/thehive-backend/app/services/LogSrv.scala @@ -28,8 +28,9 @@ class LogSrv @Inject() ( getSrv[TaskModel, Task](taskModel, taskId) .flatMap { task ⇒ create(task, fields) } - def create(task: Task, fields: Fields)(implicit authContext: AuthContext): Future[Log] = - createSrv[LogModel, Log, Task](logModel, task, fields) + def create(task: Task, fields: Fields)(implicit authContext: AuthContext): Future[Log] = { + createSrv[LogModel, Log, Task](logModel, task, fields.addIfAbsent("owner", authContext.userId)) + } def get(id: String): Future[Log] = getSrv[LogModel, Log](logModel, id) diff --git a/thehive-backend/app/services/StreamMessage.scala b/thehive-backend/app/services/StreamMessage.scala index b8dfb7de77..4fcc980303 100644 --- a/thehive-backend/app/services/StreamMessage.scala +++ b/thehive-backend/app/services/StreamMessage.scala @@ -22,9 +22,9 @@ case class AuditOperationGroup( obj: Future[JsObject], summary: Map[String, Map[String, Int]], isReady: Boolean) extends StreamMessageGroup[AuditOperation] { def :+(operation: AuditOperation): AuditOperationGroup = { - val modelSummary = summary.getOrElse(operation.entity.model.name, Map.empty[String, Int]) + val modelSummary = summary.getOrElse(operation.entity.model.modelName, Map.empty[String, Int]) val actionCount = modelSummary.getOrElse(operation.action.toString, 0) - copy(summary = summary + (operation.entity.model.name → (modelSummary + + copy(summary = summary + (operation.entity.model.modelName → (modelSummary + (operation.action.toString → (actionCount + 1))))) } @@ -34,7 +34,7 @@ case class AuditOperationGroup( Json.obj( "base" → Json.obj( "objectId" → operation.entity.id, - "objectType" → operation.entity.model.name, + "objectType" → operation.entity.model.modelName, "operation" → operation.action, "startDate" → operation.date, "rootId" → operation.entity.routing, @@ -57,7 +57,7 @@ object AuditOperationGroup { .map { case (name, value) ⇒ val baseName = name.split("\\.").head - (name, value, operation.entity.model.attributes.find(_.name == baseName)) + (name, value, operation.entity.model.attributes.find(_.attributeName == baseName)) } .collect { case (name, value, Some(attr)) if !attr.isUnaudited ⇒ (name, value) } } @@ -65,14 +65,14 @@ object AuditOperationGroup { .recover { case error ⇒ logger.error("auxSrv fails", error) - JsObject(Nil) + JsObject.empty } new AuditOperationGroup( auxSrv, operation, auditedAttributes, obj, - Map(operation.entity.model.name → Map(operation.action.toString → 1)), + Map(operation.entity.model.modelName → Map(operation.action.toString → 1)), false) } } diff --git a/thehive-backend/app/services/StreamSrv.scala b/thehive-backend/app/services/StreamSrv.scala index d19f3f9a01..c38ef7e597 100644 --- a/thehive-backend/app/services/StreamSrv.scala +++ b/thehive-backend/app/services/StreamSrv.scala @@ -16,8 +16,8 @@ import org.elastic4play.services._ import org.elastic4play.utils.Instance /** - * This actor monitors dead messages and log them - */ + * This actor monitors dead messages and log them + */ @Singleton class DeadLetterMonitoringActor @Inject() (system: ActorSystem) extends Actor { private[DeadLetterMonitoringActor] lazy val logger = Logger(getClass) @@ -80,8 +80,8 @@ class StreamActor( false) /** - * Renew timers - */ + * Renew timers + */ def renew: WaitingRequest = { if (itemCancellable.cancel()) { if (!hasResult && globalCancellable.cancel()) { @@ -103,8 +103,8 @@ class StreamActor( } /** - * Send message - */ + * Send message + */ def submit(messages: Seq[JsObject]): Unit = { itemCancellable.cancel() globalCancellable.cancel() @@ -115,8 +115,8 @@ class StreamActor( var killCancel: Cancellable = FakeCancellable /** - * renew global timer and rearm it - */ + * renew global timer and rearm it + */ def renewExpiration(): Unit = { if (killCancel.cancel()) killCancel = context.system.scheduler.scheduleOnce(cacheExpiration, self, PoisonPill) diff --git a/thehive-backend/app/services/TaskSrv.scala b/thehive-backend/app/services/TaskSrv.scala index 79be66344f..438ed20582 100644 --- a/thehive-backend/app/services/TaskSrv.scala +++ b/thehive-backend/app/services/TaskSrv.scala @@ -5,7 +5,7 @@ import javax.inject.{ Inject, Singleton } import scala.concurrent.{ ExecutionContext, Future } import scala.util.Try -import play.api.libs.json.{ JsBoolean, JsObject } +import play.api.libs.json.{ JsFalse, JsObject } import akka.NotUsed import akka.stream.Materializer @@ -64,8 +64,8 @@ class TaskSrv @Inject() ( import org.elastic4play.services.QueryDSL._ val filter = and(parent("case", withId(caseIds: _*)), "status" in (TaskStatus.Waiting.toString, TaskStatus.InProgress.toString)) val range = Some("all") - val completeTask = Fields.empty.set("status", TaskStatus.Completed.toString).set("flag", JsBoolean(false)) - val cancelTask = Fields.empty.set("status", TaskStatus.Cancel.toString).set("flag", JsBoolean(false)) + val completeTask = Fields.empty.set("status", TaskStatus.Completed.toString).set("flag", JsFalse) + val cancelTask = Fields.empty.set("status", TaskStatus.Cancel.toString).set("flag", JsFalse) find(filter, range, Nil) ._1 diff --git a/thehive-backend/app/services/TheHiveAuthSrv.scala b/thehive-backend/app/services/TheHiveAuthSrv.scala index 77f33cf868..891e287e44 100644 --- a/thehive-backend/app/services/TheHiveAuthSrv.scala +++ b/thehive-backend/app/services/TheHiveAuthSrv.scala @@ -27,10 +27,10 @@ object TheHiveAuthSrv { @Singleton class TheHiveAuthSrv @Inject() ( - configuration: Configuration, - authModules: immutable.Set[AuthSrv], - userSrv: UserSrv, - override implicit val ec: ExecutionContext) extends MultiAuthSrv( + configuration: Configuration, + authModules: immutable.Set[AuthSrv], + userSrv: UserSrv, + override implicit val ec: ExecutionContext) extends MultiAuthSrv( TheHiveAuthSrv.getAuthSrv( configuration.getDeprecated[Option[Seq[String]]]("auth.provider", "auth.type").getOrElse(Seq("local")), authModules), diff --git a/thehive-backend/app/services/UserSrv.scala b/thehive-backend/app/services/UserSrv.scala index d9da66539c..d6a2e8f630 100644 --- a/thehive-backend/app/services/UserSrv.scala +++ b/thehive-backend/app/services/UserSrv.scala @@ -45,7 +45,7 @@ class UserSrv @Inject() ( } override def getInitialUser(request: RequestHeader): Future[AuthContext] = - dbIndex.getSize(userModel.name).map { + dbIndex.getSize(userModel.modelName).map { case size if size > 0 ⇒ throw AuthenticationError(s"Use of initial user is forbidden because users exist in database") case _ ⇒ AuthContextImpl("init", "", Instance.getRequestId(request), Seq(Roles.admin, Roles.read, Roles.alert)) } diff --git a/thehive-backend/app/services/WebHook.scala b/thehive-backend/app/services/WebHook.scala index a96d2726f6..1d93c062c7 100644 --- a/thehive-backend/app/services/WebHook.scala +++ b/thehive-backend/app/services/WebHook.scala @@ -28,10 +28,10 @@ class WebHooks( auxSrv: AuxSrv, implicit val ec: ExecutionContext) { @Inject() def this( - configuration: Configuration, - globalWS: CustomWSAPI, - auxSrv: AuxSrv, - ec: ExecutionContext) = { + configuration: Configuration, + globalWS: CustomWSAPI, + auxSrv: AuxSrv, + ec: ExecutionContext) = { this( for { cfg ← configuration.getOptional[Configuration]("webhooks").toSeq @@ -50,7 +50,7 @@ class WebHooks( objectType ← (obj \ "objectType").asOpt[String] objectId ← (obj \ "objectId").asOpt[String] } yield auxSrv(objectType, objectId, nparent = 0, withStats = false, removeUnaudited = false)) - .getOrElse(Future.successful(JsObject(Nil))) + .getOrElse(Future.successful(JsObject.empty)) .map(o ⇒ obj + ("object" → o)) .fallbackTo(Future.successful(obj)) .map(o ⇒ webhooks.foreach(_.send(o))) diff --git a/thehive-backend/conf/routes b/thehive-backend/conf/routes index 7ce66025ca..ab81c946e1 100644 --- a/thehive-backend/conf/routes +++ b/thehive-backend/conf/routes @@ -4,10 +4,12 @@ GET / controllers.Default.redirect(to = "/index.html") GET /api/status controllers.StatusCtrl.get +GET /api/health controllers.StatusCtrl.health GET /api/logout controllers.AuthenticationCtrl.logout() POST /api/login controllers.AuthenticationCtrl.login() POST /api/_search controllers.SearchCtrl.find() +POST /api/_stats controllers.SearchCtrl.stats() GET /api/case controllers.CaseCtrl.find() POST /api/case/_search controllers.CaseCtrl.find() @@ -32,9 +34,9 @@ POST /api/case/artifact/_stats controllers.ArtifactCtrl.stats POST /api/case/:caseId/artifact controllers.ArtifactCtrl.create(caseId) GET /api/case/artifact/:artifactId controllers.ArtifactCtrl.get(artifactId) DELETE /api/case/artifact/:artifactId controllers.ArtifactCtrl.delete(artifactId) +PATCH /api/case/artifact/_bulk controllers.ArtifactCtrl.bulkUpdate() PATCH /api/case/artifact/:artifactId controllers.ArtifactCtrl.update(artifactId) GET /api/case/artifact/:artifactId/similar controllers.ArtifactCtrl.findSimilar(artifactId) -PATCH /api/case/artifact/_bulk controllers.ArtifactCtrl.bulkUpdate() POST /api/case/:caseId/task/_search controllers.TaskCtrl.findInCase(caseId) POST /api/case/task/_search controllers.TaskCtrl.find() @@ -100,6 +102,17 @@ POST /api/stream controllers.StreamCtrl.create( GET /api/stream/status controllers.StreamCtrl.status GET /api/stream/:streamId controllers.StreamCtrl.get(streamId) +GET /api/describe/_all controllers.DescribeCtrl.describeAll +GET /api/describe/:modelName controllers.DescribeCtrl.describe(modelName) + +GET /api/dashboard controllers.DashboardCtrl.find() +POST /api/dashboard/_search controllers.DashboardCtrl.find() +POST /api/dashboard/_stats controllers.DashboardCtrl.stats() +POST /api/dashboard controllers.DashboardCtrl.create() +GET /api/dashboard/:dashboardId controllers.DashboardCtrl.get(dashboardId) +PATCH /api/dashboard/:dashboardId controllers.DashboardCtrl.update(dashboardId) +DELETE /api/dashboard/:dashboardId controllers.DashboardCtrl.delete(dashboardId) + -> /api/connector connectors.ConnectorRouter GET /*file controllers.AssetCtrl.get(file) diff --git a/thehive-cortex/app/connectors/cortex/controllers/CortexCtrl.scala b/thehive-cortex/app/connectors/cortex/controllers/CortexCtrl.scala index a11bb6e7a7..75937a2437 100644 --- a/thehive-cortex/app/connectors/cortex/controllers/CortexCtrl.scala +++ b/thehive-cortex/app/connectors/cortex/controllers/CortexCtrl.scala @@ -2,7 +2,7 @@ package connectors.cortex.controllers import javax.inject.{ Inject, Singleton } -import scala.concurrent.ExecutionContext +import scala.concurrent.{ ExecutionContext, Future } import play.api.Logger import play.api.http.Status @@ -11,15 +11,18 @@ import play.api.mvc._ import play.api.routing.SimpleRouter import play.api.routing.sird.{ DELETE, GET, PATCH, POST, UrlContext } +import akka.actor.ActorSystem + import org.elastic4play.{ BadRequestError, NotFoundError, Timed } import org.elastic4play.controllers.{ Authenticated, Fields, FieldsBodyParser, Renderer } import org.elastic4play.models.JsonFormat.baseModelEntityWrites -import org.elastic4play.services.{ AuxSrv, QueryDSL, QueryDef } -import org.elastic4play.services.JsonFormat.queryReads +import org.elastic4play.services.{ Agg, AuxSrv, QueryDSL, QueryDef } +import org.elastic4play.services.JsonFormat.{ aggReads, queryReads } import connectors.Connector import connectors.cortex.models.JsonFormat.analyzerFormats import connectors.cortex.services.{ CortexConfig, CortexSrv } -import models.Roles +import models.HealthStatus.Type +import models.{ HealthStatus, Roles } @Singleton class CortexCtrl @Inject() ( @@ -31,17 +34,43 @@ class CortexCtrl @Inject() ( fieldsBodyParser: FieldsBodyParser, renderer: Renderer, components: ControllerComponents, - implicit val ec: ExecutionContext) extends AbstractController(components) with Connector with Status { + implicit val ec: ExecutionContext, + implicit val system: ActorSystem) extends AbstractController(components) with Connector with Status { val name = "cortex" private[CortexCtrl] lazy val logger = Logger(getClass) - override val status: JsObject = Json.obj("enabled" → true, "servers" → cortexConfig.instances.map(_.name)) + override def status: Future[JsObject] = + Future.traverse(cortexConfig.instances)(instance ⇒ instance.status()) + .map { statusDetails ⇒ + val distinctStatus = statusDetails.map(s ⇒ (s \ "status").as[String]).toSet + val healthStatus = if (distinctStatus.contains("OK")) { + if (distinctStatus.size > 1) "WARNING" else "OK" + } + else "ERROR" + Json.obj( + "enabled" → true, + "servers" → statusDetails, + "status" → healthStatus) + } + + override def health: Future[Type] = { + Future.traverse(cortexConfig.instances)(instance ⇒ instance.health()) + .map { healthStatus ⇒ + val distinctStatus = healthStatus.toSet + if (distinctStatus.contains(HealthStatus.Ok)) { + if (distinctStatus.size > 1) HealthStatus.Warning else HealthStatus.Ok + } + else if (distinctStatus.contains(HealthStatus.Error)) HealthStatus.Error + else HealthStatus.Warning + } + } val router = SimpleRouter { case POST(p"/job") ⇒ createJob case GET(p"/job/$jobId<[^/]*>") ⇒ getJob(jobId) case POST(p"/job/_search") ⇒ findJob + case POST(p"/job/_stats") ⇒ statsJob case GET(p"/analyzer/$analyzerId<[^/]*>") ⇒ getAnalyzer(analyzerId) case GET(p"/analyzer/type/$dataType<[^/]*>") ⇒ getAnalyzerFor(dataType) case GET(p"/analyzer") ⇒ listAnalyzer @@ -83,6 +112,15 @@ class CortexCtrl @Inject() ( renderer.toOutput(OK, jobWithoutReport, total) } + @Timed + def statsJob: Action[Fields] = authenticated(Roles.read).async(fieldsBodyParser) { implicit request ⇒ + val query = request.body.getValue("query") + .fold[QueryDef](QueryDSL.any)(_.as[QueryDef]) + val aggs = request.body.getValue("stats") + .getOrElse(throw BadRequestError("Parameter \"stats\" is missing")).as[Seq[Agg]] + cortexSrv.stats(query, aggs).map(s ⇒ Ok(s)) + } + @Timed def getAnalyzer(analyzerId: String): Action[AnyContent] = authenticated(Roles.read).async { implicit request ⇒ cortexSrv.getAnalyzer(analyzerId).map { analyzer ⇒ diff --git a/thehive-cortex/app/connectors/cortex/controllers/ReportTemplateCtrl.scala b/thehive-cortex/app/connectors/cortex/controllers/ReportTemplateCtrl.scala index c669933998..d7498cf638 100644 --- a/thehive-cortex/app/connectors/cortex/controllers/ReportTemplateCtrl.scala +++ b/thehive-cortex/app/connectors/cortex/controllers/ReportTemplateCtrl.scala @@ -11,7 +11,7 @@ import akka.stream.Materializer import akka.stream.scaladsl.Sink import play.api.Logger import play.api.http.Status -import play.api.libs.json.{ JsBoolean, JsObject } +import play.api.libs.json.{ JsBoolean, JsFalse, JsObject, JsTrue } import play.api.mvc._ import org.elastic4play.{ BadRequestError, Timed } @@ -115,12 +115,12 @@ class ReportTemplateCtrl @Inject() ( val reportTemplateId = analyzerId + "_" + reportType reportTemplateSrv.update(reportTemplateId, Fields.empty.set("content", content)) } - .map(_.id → JsBoolean(true)) + .map(_.id → JsTrue) .recoverWith { case NonFatal(e) ⇒ logger.error(s"The import of the report template $analyzerId ($reportType) has failed", e) val reportTemplateId = analyzerId + "_" + reportType - Future.successful(reportTemplateId → JsBoolean(false)) + Future.successful(reportTemplateId → JsFalse) } } diff --git a/thehive-cortex/app/connectors/cortex/models/Job.scala b/thehive-cortex/app/connectors/cortex/models/Job.scala index 6610183082..6748dde08e 100644 --- a/thehive-cortex/app/connectors/cortex/models/Job.scala +++ b/thehive-cortex/app/connectors/cortex/models/Job.scala @@ -33,7 +33,7 @@ trait JobAttributes { _: AttributeDef ⇒ } @Singleton -class JobModel @Inject() (artifactModel: ArtifactModel) extends ChildModelDef[JobModel, Job, ArtifactModel, Artifact](artifactModel, "case_artifact_job") with JobAttributes with AuditedModel { +class JobModel @Inject() (artifactModel: ArtifactModel) extends ChildModelDef[JobModel, Job, ArtifactModel, Artifact](artifactModel, "case_artifact_job", "Job", "/connector/cortex/job") with JobAttributes with AuditedModel { override def creationHook(parent: Option[BaseEntity], attrs: JsObject): Future[JsObject] = Future.successful { attrs @@ -42,7 +42,7 @@ class JobModel @Inject() (artifactModel: ArtifactModel) extends ChildModelDef[Jo } } class Job(model: JobModel, attributes: JsObject) extends EntityDef[JobModel, Job](model, attributes) with JobAttributes { - override def toJson = super.toJson + ("report" → report().fold[JsValue](JsObject(Nil))(r ⇒ Json.parse(r))) // FIXME is parse fails (invalid report) + override def toJson = super.toJson + ("report" → report().fold[JsValue](JsObject.empty)(r ⇒ Json.parse(r))) // FIXME is parse fails (invalid report) } case class CortexJob(id: String, analyzerId: String, artifact: CortexArtifact, date: Date, status: JobStatus.Type, cortexIds: List[String] = Nil) { diff --git a/thehive-cortex/app/connectors/cortex/models/ReportTemplate.scala b/thehive-cortex/app/connectors/cortex/models/ReportTemplate.scala index b2422a86f8..1990d98766 100644 --- a/thehive-cortex/app/connectors/cortex/models/ReportTemplate.scala +++ b/thehive-cortex/app/connectors/cortex/models/ReportTemplate.scala @@ -25,7 +25,7 @@ trait ReportTemplateAttributes { _: AttributeDef ⇒ } @Singleton -class ReportTemplateModel @Inject() extends ModelDef[ReportTemplateModel, ReportTemplate]("reportTemplate") with ReportTemplateAttributes { +class ReportTemplateModel @Inject() extends ModelDef[ReportTemplateModel, ReportTemplate]("reportTemplate", "Report template", "/connector/cortex/reportTemplate") with ReportTemplateAttributes { override def creationHook(parent: Option[BaseEntity], attrs: JsObject) = { val maybeId = for { analyzerId ← (attrs \ "analyzerId").asOpt[String] diff --git a/thehive-cortex/app/connectors/cortex/services/CortexClient.scala b/thehive-cortex/app/connectors/cortex/services/CortexClient.scala index 9a74b8943f..477b014b3a 100644 --- a/thehive-cortex/app/connectors/cortex/services/CortexClient.scala +++ b/thehive-cortex/app/connectors/cortex/services/CortexClient.scala @@ -1,30 +1,52 @@ package connectors.cortex.services -import akka.stream.scaladsl.Source -import connectors.cortex.models.JsonFormat._ -import connectors.cortex.models.{ Analyzer, CortexArtifact, DataArtifact, FileArtifact } +import scala.concurrent.duration._ +import scala.concurrent.{ ExecutionContext, Future } + import play.api.Logger +import play.api.http.HeaderNames import play.api.libs.json.{ JsObject, JsValue, Json } import play.api.libs.ws.{ WSAuthScheme, WSRequest, WSResponse } -import play.api.libs.ws.WSBodyWritables.writeableOf_JsValue import play.api.mvc.MultipartFormData.{ DataPart, FilePart } + +import akka.actor.ActorSystem +import akka.stream.scaladsl.Source +import connectors.cortex.models.JsonFormat._ +import connectors.cortex.models.{ Analyzer, CortexArtifact, DataArtifact, FileArtifact } +import models.HealthStatus import services.CustomWSAPI -import scala.concurrent.duration.Duration -import scala.concurrent.{ ExecutionContext, Future } +import org.elastic4play.utils.RichFuture + +object CortexAuthentication { + + abstract class Type { + def apply(request: WSRequest): WSRequest + } + + case class Basic(username: String, password: String) extends Type { + def apply(request: WSRequest): WSRequest = { + request.withAuth(username, password, WSAuthScheme.BASIC) + } + } + + case class Key(key: String) extends Type { + def apply(request: WSRequest): WSRequest = { + request.withHttpHeaders(HeaderNames.AUTHORIZATION → s"Bearer $key") + } + } +} case class CortexError(status: Int, requestUrl: String, message: String) extends Exception(s"Cortex error on $requestUrl ($status) \n$message") -class CortexClient(val name: String, baseUrl: String, key: String, authentication: Option[(String, String)], ws: CustomWSAPI) { +class CortexClient(val name: String, baseUrl: String, authentication: Option[CortexAuthentication.Type], ws: CustomWSAPI) { private[CortexClient] lazy val logger = Logger(getClass) - logger.info(s"new Cortex($name, $baseUrl, $key) Basic Auth enabled: ${authentication.isDefined}") - def request[A](uri: String, f: WSRequest ⇒ Future[WSResponse], t: WSResponse ⇒ A)(implicit ec: ExecutionContext): Future[A] = { - val requestBuilder = ws.url(s"$baseUrl/$uri").withHttpHeaders("auth" → key) - val authenticatedRequestBuilder = authentication.fold(requestBuilder) { - case (username, password) ⇒ requestBuilder.withAuth(username, password, WSAuthScheme.BASIC) - } - f(authenticatedRequestBuilder).map { + logger.info(s"new Cortex($name, $baseUrl) authentication: ${authentication.fold("no")(_.getClass.getName)}") + private def request[A](uri: String, f: WSRequest ⇒ Future[WSResponse], t: WSResponse ⇒ A)(implicit ec: ExecutionContext): Future[A] = { + val request = ws.url(s"$baseUrl/$uri") + val authenticatedRequest = authentication.fold(request)(_.apply(request)) + f(authenticatedRequest).map { case response if response.status / 100 == 2 ⇒ t(response) case error ⇒ throw CortexError(error.status, s"$baseUrl/$uri", error.body) } @@ -54,23 +76,54 @@ class CortexClient(val name: String, baseUrl: String, key: String, authenticatio request(s"api/analyzer/type/$dataType", _.get, _.json.as[Seq[Analyzer]]).map(_.map(_.copy(cortexIds = List(name)))) } - def listJob(implicit ec: ExecutionContext): Future[Seq[JsObject]] = { - request(s"api/job", _.get, _.json.as[Seq[JsObject]]) - } + // def listJob(implicit ec: ExecutionContext): Future[Seq[JsObject]] = { + // request(s"api/job", _.get, _.json.as[Seq[JsObject]]) + // } - def getJob(jobId: String)(implicit ec: ExecutionContext): Future[JsObject] = { - request(s"api/job/$jobId", _.get, _.json.as[JsObject]) - } + // def getJob(jobId: String)(implicit ec: ExecutionContext): Future[JsObject] = { + // request(s"api/job/$jobId", _.get, _.json.as[JsObject]) + // } + + // def removeJob(jobId: String)(implicit ec: ExecutionContext): Future[Unit] = { + // request(s"api/job/$jobId", _.delete, _ ⇒ ()) + // } - def removeJob(jobId: String)(implicit ec: ExecutionContext): Future[Unit] = { - request(s"api/job/$jobId", _.delete, _ ⇒ ()) + // def report(jobId: String)(implicit ec: ExecutionContext): Future[JsObject] = { + // request(s"api/job/$jobId/report", _.get, _.json.as[JsObject]) + // } + + def waitReport(jobId: String, atMost: Duration)(implicit ec: ExecutionContext): Future[JsObject] = { + request(s"api/job/$jobId/waitreport", _.withQueryStringParameters("atMost" → atMost.toString).get, _.json.as[JsObject]) } - def report(jobId: String)(implicit ec: ExecutionContext): Future[JsObject] = { - request(s"api/job/$jobId/report", _.get, r ⇒ r.json.as[JsObject]) + def getVersion()(implicit system: ActorSystem, ec: ExecutionContext): Future[Option[String]] = { + request("api/status", _.get, identity) + .map { + case resp if resp.status / 100 == 2 ⇒ (resp.json \ "versions" \ "Cortex").asOpt[String] + case _ ⇒ None + } + .recover { case _ ⇒ None } + .withTimeout(1.seconds, None) } - def waitReport(jobId: String, atMost: Duration)(implicit ec: ExecutionContext): Future[JsObject] = { - request(s"api/job/$jobId/waitreport", _.withQueryStringParameters("atMost" → atMost.toString).get, r ⇒ r.json.as[JsObject]) + def status()(implicit system: ActorSystem, ec: ExecutionContext): Future[JsObject] = + getVersion() + .map { + case Some(version) ⇒ Json.obj( + "name" → name, + "version" → version, + "status" → "OK") + case None ⇒ Json.obj( + "name" → name, + "version" → "", + "status" → "ERROR") + } + + def health()(implicit system: ActorSystem, ec: ExecutionContext): Future[HealthStatus.Type] = { + getVersion() + .map { + case None ⇒ HealthStatus.Error + case _ ⇒ HealthStatus.Ok + } } -} +} \ No newline at end of file diff --git a/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala b/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala index 4d25208792..b903cdbd97 100644 --- a/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala +++ b/thehive-cortex/app/connectors/cortex/services/CortexSrv.scala @@ -27,14 +27,17 @@ import scala.util.{ Failure, Success, Try } object CortexConfig { def getCortexClient(name: String, configuration: Configuration, ws: CustomWSAPI): Option[CortexClient] = { val url = configuration.getOptional[String]("url").getOrElse(sys.error("url is missing")).replaceFirst("/*$", "") - val key = "" // configuration.getString("key").getOrElse(sys.error("key is missing")) - val authentication = for { - basicEnabled ← configuration.getOptional[Boolean]("basicAuth") - if basicEnabled - username ← configuration.getOptional[String]("username") - password ← configuration.getOptional[String]("password") - } yield username → password - Some(new CortexClient(name, url, key, authentication, ws)) + val authentication = + configuration.getOptional[String]("key").map(CortexAuthentication.Key) + .orElse { + for { + basicEnabled ← configuration.getOptional[Boolean]("basicAuth") + if basicEnabled + username ← configuration.getOptional[String]("username") + password ← configuration.getOptional[String]("password") + } yield CortexAuthentication.Basic(username, password) + } + Some(new CortexClient(name, url, authentication, ws)) } def getInstances(configuration: Configuration, globalWS: CustomWSAPI): Seq[CortexClient] = { @@ -140,6 +143,8 @@ class CortexSrv @Inject() ( findSrv[JobModel, Job](jobModel, queryDef, range, sortBy) } + def stats(query: QueryDef, aggs: Seq[Agg]) = findSrv(jobModel, query, aggs: _*) + def getAnalyzer(analyzerId: String): Future[Analyzer] = { Future .traverse(cortexConfig.instances) { cortex ⇒ @@ -205,7 +210,7 @@ class CortexSrv @Inject() ( if (status == JobStatus.InProgress) updateJobWithCortex(jobId, cortexJobId, cortex) else { - val report = (j \ "report").asOpt[JsObject].getOrElse(JsObject(Nil)).toString + val report = (j \ "report").asOpt[JsObject].getOrElse(JsObject.empty).toString logger.debug(s"Job $cortexJobId in cortex ${cortex.name} has finished with status $status, updating job $jobId") getSrv[JobModel, Job](jobModel, jobId) .flatMap { job ⇒ @@ -219,10 +224,10 @@ class CortexSrv @Inject() ( val jobSummary = Try(Json.parse(report)) .toOption .flatMap(r ⇒ (r \ "summary").asOpt[JsObject]) - .getOrElse(JsObject(Nil)) + .getOrElse(JsObject.empty) for { artifact ← artifactSrv.get(job.artifactId()) - reports = Try(Json.parse(artifact.reports()).asOpt[JsObject]).toOption.flatten.getOrElse(JsObject(Nil)) + reports = Try(Json.parse(artifact.reports()).asOpt[JsObject]).toOption.flatten.getOrElse(JsObject.empty) newReports = reports + (job.analyzerId() → jobSummary) } artifactSrv.update(job.artifactId(), Fields.empty.set("reports", newReports.toString)) .recover { diff --git a/thehive-cortex/app/connectors/cortex/services/ReportTemplateSrv.scala b/thehive-cortex/app/connectors/cortex/services/ReportTemplateSrv.scala index ee056b3cf0..672ca4e018 100644 --- a/thehive-cortex/app/connectors/cortex/services/ReportTemplateSrv.scala +++ b/thehive-cortex/app/connectors/cortex/services/ReportTemplateSrv.scala @@ -54,6 +54,6 @@ class ReportTemplateSrv @Inject() ( def stats(queryDef: QueryDef, aggs: Seq[Agg]): Future[JsObject] = findSrv(reportTemplateModel, queryDef, aggs: _*) def getStats(id: String): Future[JsObject] = { - Future.successful(JsObject(Nil)) + Future.successful(JsObject.empty) } } \ No newline at end of file diff --git a/thehive-metrics/app/connectors/metrics/Influxdb.scala b/thehive-metrics/app/connectors/metrics/Influxdb.scala index ad57ed0426..74cea35d03 100644 --- a/thehive-metrics/app/connectors/metrics/Influxdb.scala +++ b/thehive-metrics/app/connectors/metrics/Influxdb.scala @@ -81,11 +81,11 @@ class InfluxDBReporter( tags: Map[String, String]) extends ScheduledReporter(registry, "influxdb-reporter", filter, rateUnit, durationUnit) { def report( - gauges: util.SortedMap[String, Gauge[_]], - counters: util.SortedMap[String, Counter], - histograms: util.SortedMap[String, Histogram], - meters: util.SortedMap[String, Meter], - timers: util.SortedMap[String, Timer]): Unit = { + gauges: util.SortedMap[String, Gauge[_]], + counters: util.SortedMap[String, Counter], + histograms: util.SortedMap[String, Histogram], + meters: util.SortedMap[String, Meter], + timers: util.SortedMap[String, Timer]): Unit = { val now = System.currentTimeMillis() * 1000000 diff --git a/thehive-metrics/app/connectors/metrics/MetricsFilter.scala b/thehive-metrics/app/connectors/metrics/MetricsFilter.scala index 65bcfe69ab..87f27e3033 100644 --- a/thehive-metrics/app/connectors/metrics/MetricsFilter.scala +++ b/thehive-metrics/app/connectors/metrics/MetricsFilter.scala @@ -24,22 +24,22 @@ class NoMetricsFilter @Inject() (implicit val mat: Materializer) extends Metrics class MetricsFilterImpl @Inject() (metricsModule: Metrics, implicit val mat: Materializer, implicit val ec: ExecutionContext) extends MetricsFilter { /** - * Specify a meaningful prefix for metrics - * - * Defaults to classOf[MetricsFilter].getName for backward compatibility as - * this was the original set value. - * - */ + * Specify a meaningful prefix for metrics + * + * Defaults to classOf[MetricsFilter].getName for backward compatibility as + * this was the original set value. + * + */ def labelPrefix: String = classOf[MetricsFilter].getName /** - * Specify which HTTP status codes have individual metrics - * - * Statuses not specified here are grouped together under otherStatuses - * - * Defaults to 200, 400, 401, 403, 404, 409, 201, 304, 307, 500, which is compatible - * with prior releases. - */ + * Specify which HTTP status codes have individual metrics + * + * Statuses not specified here are grouped together under otherStatuses + * + * Defaults to 200, 400, 401, 403, 404, 409, 201, 304, 307, 500, which is compatible + * with prior releases. + */ def knownStatuses = Seq(Status.OK, Status.BAD_REQUEST, Status.FORBIDDEN, Status.NOT_FOUND, Status.CREATED, Status.TEMPORARY_REDIRECT, Status.INTERNAL_SERVER_ERROR, Status.CONFLICT, Status.UNAUTHORIZED, Status.NOT_MODIFIED) diff --git a/thehive-misp/app/connectors/misp/JsonFormat.scala b/thehive-misp/app/connectors/misp/JsonFormat.scala index 2b7234e013..f6590882b0 100644 --- a/thehive-misp/app/connectors/misp/JsonFormat.scala +++ b/thehive-misp/app/connectors/misp/JsonFormat.scala @@ -69,12 +69,22 @@ object JsonFormat { value, tags)) + val tlpWrites: Writes[Long] = Writes[Long] { + case 0 ⇒ JsString("tlp:white") + case 1 ⇒ JsString("tlp:green") + case 2 ⇒ JsString("tlp:amber") + case 3 ⇒ JsString("tlp:red") + case _ ⇒ JsString("tlp:amber") + } + implicit val exportedAttributeWrites: Writes[ExportedMispAttribute] = Writes[ExportedMispAttribute] { attribute ⇒ Json.obj( "category" → attribute.category, "type" → attribute.tpe, "value" → attribute.value.fold[String](identity, _.name), - "comment" → attribute.comment) + "comment" → attribute.comment, + "Tag" → Json.arr( + Json.obj("name" → tlpWrites.writes(attribute.tlp)))) } implicit val mispArtifactWrites: Writes[MispArtifact] = OWrites[MispArtifact] { artifact ⇒ diff --git a/thehive-misp/app/connectors/misp/MispConnection.scala b/thehive-misp/app/connectors/misp/MispConnection.scala index 4bc4db9c21..ea1fa84dbf 100644 --- a/thehive-misp/app/connectors/misp/MispConnection.scala +++ b/thehive-misp/app/connectors/misp/MispConnection.scala @@ -1,9 +1,18 @@ package connectors.misp +import scala.concurrent.duration._ +import scala.concurrent.{ ExecutionContext, Future } + import play.api.Logger +import play.api.libs.json.{ JsObject, Json } +import play.api.libs.ws.WSRequest +import akka.actor.ActorSystem +import models.HealthStatus import services.CustomWSAPI +import org.elastic4play.utils.RichFuture + case class MispConnection( name: String, baseUrl: String, @@ -21,10 +30,41 @@ case class MispConnection( |\tcase template: ${caseTemplate.getOrElse("")} |\tartifact tags: ${artifactTags.mkString}""".stripMargin) - private[misp] def apply(url: String) = + private[misp] def apply(url: String): WSRequest = ws.url(s"$baseUrl/$url") .withHttpHeaders( "Authorization" → key, "Accept" → "application/json") + def getVersion()(implicit system: ActorSystem, ec: ExecutionContext): Future[Option[String]] = { + apply("servers/getVersion").get + .map { + case resp if resp.status / 100 == 2 ⇒ (resp.json \ "version").asOpt[String] + case _ ⇒ None + } + .recover { case _ ⇒ None } + .withTimeout(1.seconds, None) + } + + def status()(implicit system: ActorSystem, ec: ExecutionContext): Future[JsObject] = { + getVersion() + .map { + case Some(version) ⇒ Json.obj( + "name" → name, + "version" → version, + "status" → "OK") + case None ⇒ Json.obj( + "name" → name, + "version" → "", + "status" → "ERROR") + } + } + + def healthStatus()(implicit system: ActorSystem, ec: ExecutionContext): Future[HealthStatus.Type] = { + getVersion() + .map { + case None ⇒ HealthStatus.Error + case _ ⇒ HealthStatus.Ok + } + } } \ No newline at end of file diff --git a/thehive-misp/app/connectors/misp/MispConverter.scala b/thehive-misp/app/connectors/misp/MispConverter.scala index c4e5446bde..c145e0e191 100644 --- a/thehive-misp/app/connectors/misp/MispConverter.scala +++ b/thehive-misp/app/connectors/misp/MispConverter.scala @@ -6,22 +6,22 @@ trait MispConverter { if (mispAttribute.tpe == "attachment" || mispAttribute.tpe == "malware-sample") { Seq( MispArtifact( - value = RemoteAttachmentArtifact(mispAttribute.value.split("\\|").head, mispAttribute.id, mispAttribute.tpe), - dataType = "file", - message = mispAttribute.comment, - tlp = 0, - tags = tags ++ mispAttribute.tags, + value = RemoteAttachmentArtifact(mispAttribute.value.split("\\|").head, mispAttribute.id, mispAttribute.tpe), + dataType = "file", + message = mispAttribute.comment, + tlp = 0, + tags = tags ++ mispAttribute.tags, startDate = mispAttribute.date)) } else { val dataType = toArtifact(mispAttribute.tpe) val artifact = MispArtifact( - value = SimpleArtifactData(mispAttribute.value), - dataType = dataType, - message = mispAttribute.comment, - tlp = 0, - tags = tags ++ mispAttribute.tags, + value = SimpleArtifactData(mispAttribute.value), + dataType = dataType, + message = mispAttribute.comment, + tlp = 0, + tags = tags ++ mispAttribute.tags, startDate = mispAttribute.date) val types = mispAttribute.tpe.split('|').toSeq @@ -37,8 +37,8 @@ trait MispConverter { case (tpe, value) ⇒ artifact.copy( dataType = toArtifact(tpe), - value = SimpleArtifactData(value), - message = mispAttribute.comment + "\n" + additionnalMessage) + value = SimpleArtifactData(value), + message = mispAttribute.comment + "\n" + additionnalMessage) } } else { diff --git a/thehive-misp/app/connectors/misp/MispCtrl.scala b/thehive-misp/app/connectors/misp/MispCtrl.scala index 315d79995c..978d426738 100644 --- a/thehive-misp/app/connectors/misp/MispCtrl.scala +++ b/thehive-misp/app/connectors/misp/MispCtrl.scala @@ -11,8 +11,9 @@ import play.api.mvc._ import play.api.routing.SimpleRouter import play.api.routing.sird.{ GET, POST, UrlContext } +import akka.actor.ActorSystem import connectors.Connector -import models.{ Alert, Case, Roles, UpdateMispAlertArtifact } +import models._ import services.{ AlertTransformer, CaseSrv } import org.elastic4play.JsonFormat.tryWrites @@ -32,11 +33,36 @@ class MispCtrl @Inject() ( renderer: Renderer, eventSrv: EventSrv, components: ControllerComponents, - implicit val ec: ExecutionContext) extends AbstractController(components) with Connector with Status with AlertTransformer { + implicit val ec: ExecutionContext, + implicit val system: ActorSystem) extends AbstractController(components) with Connector with Status with AlertTransformer { override val name: String = "misp" - override val status: JsObject = Json.obj("enabled" → true, "servers" → mispConfig.connections.map(_.name)) + override def status: Future[JsObject] = + Future.traverse(mispConfig.connections)(_.status()) + .map { statusDetails ⇒ + val distinctStatus = statusDetails.map(s ⇒ (s \ "status").as[String]).toSet + val healthStatus = if (distinctStatus.contains("OK")) { + if (distinctStatus.size > 1) "WARNING" else "OK" + } + else "ERROR" + Json.obj( + "enabled" → true, + "servers" → statusDetails, + "status" → healthStatus) + } + + override def health: Future[HealthStatus.Type] = { + Future.traverse(mispConfig.connections)(_.healthStatus()) + .map { healthStatus ⇒ + val distinctStatus = healthStatus.toSet + if (distinctStatus.contains(HealthStatus.Ok)) { + if (distinctStatus.size > 1) HealthStatus.Warning else HealthStatus.Ok + } + else if (distinctStatus.contains(HealthStatus.Error)) HealthStatus.Error + else HealthStatus.Warning + } + } private[MispCtrl] lazy val logger = Logger(getClass) val router = SimpleRouter { diff --git a/thehive-misp/app/connectors/misp/MispExport.scala b/thehive-misp/app/connectors/misp/MispExport.scala index d6d4060cfe..6f266f58b3 100644 --- a/thehive-misp/app/connectors/misp/MispExport.scala +++ b/thehive-misp/app/connectors/misp/MispExport.scala @@ -10,6 +10,7 @@ import scala.util.{ Success, Try } import play.api.libs.json.{ JsObject, Json } import akka.stream.scaladsl.Sink +import connectors.misp.JsonFormat.tlpWrites import models.{ Artifact, Case } import services.{ AlertSrv, ArtifactSrv } import JsonFormat.exportedAttributeWrites @@ -48,15 +49,15 @@ class MispExport @Inject() ( attrIndex .filter { - case (ExportedMispAttribute(_, category, tpe, value, _), index) ⇒ attrIndex.exists { - case (ExportedMispAttribute(_, `category`, `tpe`, `value`, _), otherIndex) ⇒ otherIndex >= index + case (ExportedMispAttribute(_, category, tpe, _, value, _), index) ⇒ attrIndex.exists { + case (ExportedMispAttribute(_, `category`, `tpe`, _, `value`, _), otherIndex) ⇒ otherIndex >= index case _ ⇒ true } } .map(_._1) } - def createEvent(mispConnection: MispConnection, title: String, severity: Long, date: Date, attributes: Seq[ExportedMispAttribute]): Future[(String, Seq[ExportedMispAttribute])] = { + def createEvent(mispConnection: MispConnection, title: String, severity: Long, tlp: Long, date: Date, attributes: Seq[ExportedMispAttribute]): Future[(String, Seq[ExportedMispAttribute])] = { val mispEvent = Json.obj( "Event" → Json.obj( "distribution" → 0, @@ -65,7 +66,9 @@ class MispExport @Inject() ( "info" → title, "date" → dateFormat.format(date), "published" → false, - "Attribute" → attributes)) + "Attribute" → attributes, + "Tag" → Json.arr( + Json.obj("name" → tlpWrites.writes(tlp))))) mispConnection("events") .post(mispEvent) .map { mispResponse ⇒ @@ -74,7 +77,7 @@ class MispExport @Inject() ( .getOrElse(throw InternalError(s"Unexpected MISP response: ${mispResponse.status} ${mispResponse.statusText}\n${mispResponse.body}")) val messages = (mispResponse.json \ "errors" \ "Attribute") .asOpt[JsObject] - .getOrElse(JsObject(Nil)) + .getOrElse(JsObject.empty) .fields .toMap .mapValues { m ⇒ @@ -92,7 +95,7 @@ class MispExport @Inject() ( def exportAttribute(mispConnection: MispConnection, eventId: String, attribute: ExportedMispAttribute): Future[Artifact] = { val mispResponse = attribute match { - case ExportedMispAttribute(_, _, _, Right(attachment), comment) ⇒ + case ExportedMispAttribute(_, _, _, _, Right(attachment), comment) ⇒ attachmentSrv .source(attachment.id) .runReduce(_ ++ _) @@ -111,11 +114,18 @@ class MispExport @Inject() ( mispConnection("events/upload_sample").post(body) } case attr ⇒ mispConnection(s"attributes/add/$eventId").post(Json.toJson(attr)) - } mispResponse.map { - case response if response.status / 100 == 2 ⇒ attribute.artifact + case response if response.status / 100 == 2 ⇒ + // then add tlp tag + // doesn't work with file artifact (malware sample attribute) + (response.json \ "Attribute" \ "id").asOpt[String] + .foreach { attributeId ⇒ + mispConnection("/attributes/addTag") + .post(Json.obj("attribute" → attributeId, "tag" → tlpWrites.writes(attribute.tlp))) + } + attribute.artifact case response ⇒ val json = response.json val message = (json \ "message").asOpt[String] @@ -135,7 +145,7 @@ class MispExport @Inject() ( (eventId, initialExportesArtifacts, existingAttributes) ← maybeEventId.fold { val simpleAttributes = uniqueAttributes.filter(_.value.isLeft) // if no event is associated to this case, create a new one - createEvent(mispConnection, caze.title(), caze.severity(), caze.startDate(), simpleAttributes).map { + createEvent(mispConnection, caze.title(), caze.severity(), caze.tlp(), caze.startDate(), simpleAttributes).map { case (eventId, exportedAttributes) ⇒ (eventId, exportedAttributes.map(a ⇒ Success(a.artifact)), exportedAttributes.map(_.value.map(_.name))) } } { eventId ⇒ // if an event already exists, retrieve its attributes in order to export only new one diff --git a/thehive-misp/app/connectors/misp/MispModel.scala b/thehive-misp/app/connectors/misp/MispModel.scala index 37f6c8dc2a..cbe7f69669 100644 --- a/thehive-misp/app/connectors/misp/MispModel.scala +++ b/thehive-misp/app/connectors/misp/MispModel.scala @@ -20,40 +20,41 @@ case class AttachmentArtifact(attachment: Attachment) extends ArtifactData { case class RemoteAttachmentArtifact(filename: String, reference: String, tpe: String) extends ArtifactData case class MispAlert( - source: String, - sourceRef: String, - date: Date, - lastSyncDate: Date, - isPublished: Boolean, - title: String, - description: String, - severity: Long, - tags: Seq[String], - tlp: Long, - caseTemplate: String) + source: String, + sourceRef: String, + date: Date, + lastSyncDate: Date, + isPublished: Boolean, + title: String, + description: String, + severity: Long, + tags: Seq[String], + tlp: Long, + caseTemplate: String) case class MispAttribute( - id: String, - category: String, - tpe: String, - date: Date, - comment: String, - value: String, - tags: Seq[String]) + id: String, + category: String, + tpe: String, + date: Date, + comment: String, + value: String, + tags: Seq[String]) case class ExportedMispAttribute( - artifact: Artifact, - tpe: String, - category: String, - value: Either[String, Attachment], - comment: Option[String]) + artifact: Artifact, + tpe: String, + category: String, + tlp: Long, + value: Either[String, Attachment], + comment: Option[String]) case class MispArtifact( - value: ArtifactData, - dataType: String, - message: String, - tlp: Long, - tags: Seq[String], - startDate: Date) + value: ArtifactData, + dataType: String, + message: String, + tlp: Long, + tags: Seq[String], + startDate: Date) case class MispExportError(message: String, artifact: Artifact) extends ErrorWithObject(message, artifact.attributes) \ 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 5eba30ed0b..b9870940d0 100644 --- a/thehive-misp/app/connectors/misp/MispSrv.scala +++ b/thehive-misp/app/connectors/misp/MispSrv.scala @@ -91,15 +91,15 @@ class MispSrv @Inject() ( logger.error(s"Artifact $artifact has neither data nor attachment") sys.error("???") } - ExportedMispAttribute(artifact, tpe, category, value, artifact.message()) + ExportedMispAttribute(artifact, tpe, category, artifact.tlp(), value, artifact.message()) } .runWith(Sink.seq) } def getAttributesFromMisp( - mispConnection: MispConnection, - eventId: String, - fromDate: Option[Date]): Future[Seq[MispArtifact]] = { + mispConnection: MispConnection, + eventId: String, + fromDate: Option[Date]): Future[Seq[MispArtifact]] = { val date = fromDate.fold(0L)(_.getTime / 1000) @@ -126,16 +126,16 @@ class MispSrv @Inject() ( .map { mispArtifact ⇒ mispArtifact.head.copy( tags = (mispArtifact.head.tags ++ artifactTags).distinct, - tlp = 2L) + tlp = 2L) } .toSeq } } def attributeToArtifact( - mispConnection: MispConnection, - attr: JsObject, - defaultTlp: Long)(implicit authContext: AuthContext): Option[Future[Fields]] = { + mispConnection: MispConnection, + attr: JsObject, + defaultTlp: Long)(implicit authContext: AuthContext): Option[Future[Fields]] = { (for { dataType ← (attr \ "dataType").validate[String] data ← (attr \ "data").validateOpt[String] @@ -204,7 +204,7 @@ class MispSrv @Inject() ( def mergeWithCase(alert: Alert, caze: Case)(implicit authContext: AuthContext): Future[Case] = { for { _ ← importArtifacts(alert, caze) - description = caze.description() + s"\n \n#### Merged with MISP event ${alert.title()}" + description = caze.description() + s"\n \n#### Merged with MISP event ${alert.title()}\n\n${alert.description().trim}" updatedCase ← caseSrv.update(caze, Fields.empty.set("description", description)) } yield updatedCase } @@ -292,8 +292,8 @@ class MispSrv @Inject() ( private[MispSrv] val fileNameExtractor = """attachment; filename="(.*)"""".r def downloadAttachment( - mispConnection: MispConnection, - attachmentId: String)(implicit authContext: AuthContext): Future[FileInputValue] = { + mispConnection: MispConnection, + attachmentId: String)(implicit authContext: AuthContext): Future[FileInputValue] = { mispConnection(s"attributes/download/$attachmentId") .withMethod("GET") diff --git a/thehive-misp/app/connectors/misp/UpdateMispAlertArtifactActor.scala b/thehive-misp/app/connectors/misp/UpdateMispAlertArtifactActor.scala index ba6989cb15..13aab47846 100644 --- a/thehive-misp/app/connectors/misp/UpdateMispAlertArtifactActor.scala +++ b/thehive-misp/app/connectors/misp/UpdateMispAlertArtifactActor.scala @@ -14,14 +14,14 @@ import services.UserSrv import org.elastic4play.services.EventSrv /** - * This actor listens message from migration (message UpdateMispAlertArtifact) which indicates that artifacts in - * MISP event must be retrieved in inserted in alerts. - * - * @param eventSrv event bus used to receive migration message - * @param userSrv user service used to do operations on database without real user request - * @param mispSrv misp service to invoke artifact update action - * @param ec execution context - */ + * This actor listens message from migration (message UpdateMispAlertArtifact) which indicates that artifacts in + * MISP event must be retrieved in inserted in alerts. + * + * @param eventSrv event bus used to receive migration message + * @param userSrv user service used to do operations on database without real user request + * @param mispSrv misp service to invoke artifact update action + * @param ec execution context + */ @Singleton class UpdateMispAlertArtifactActor @Inject() ( eventSrv: EventSrv, diff --git a/ui/Gruntfile.js b/ui/Gruntfile.js index bc8b3c5d59..72539ce0ae 100644 --- a/ui/Gruntfile.js +++ b/ui/Gruntfile.js @@ -82,7 +82,7 @@ module.exports = function(grunt) { options: { port: 3001, // Change this to '0.0.0.0' to access the server from outside. - hostname: 'localhost', + hostname: '0.0.0.0', livereload: 35729 }, proxies: [{ diff --git a/ui/app/index.html b/ui/app/index.html index 74a0f6a452..0634648432 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -31,6 +31,7 @@ + @@ -45,7 +46,9 @@ + + @@ -108,6 +111,9 @@ + + + @@ -133,7 +139,6 @@ - @@ -163,14 +168,17 @@ + + + - - - - - + + + + + @@ -222,11 +230,13 @@ + + @@ -235,9 +245,11 @@ + + @@ -246,7 +258,6 @@ - diff --git a/ui/app/scripts/app.js b/ui/app/scripts/app.js index 8dfb0b5132..4bd3dd4d7f 100644 --- a/ui/app/scripts/app.js +++ b/ui/app/scripts/app.js @@ -7,7 +7,8 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra 'theHiveControllers', 'theHiveServices', 'theHiveFilters', 'theHiveDirectives', 'yaru22.jsonHuman', 'timer', 'angularMoment', 'ngCsv', 'ngTagsInput', 'btford.markdown', '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' + 'LocalStorageModule', 'angular-markdown-editor', 'hc.marked', 'hljs', 'ui.ace', 'angular-page-loader', 'naif.base64', 'images-resizer', 'duScroll', + 'dndLists', 'colorpicker.module' ]) .config(function($resourceProvider) { 'use strict'; @@ -122,12 +123,6 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra } } }) - .state('app.statistics', { - url: 'statistics', - templateUrl: 'views/partials/statistics.html', - controller: 'StatisticsCtrl', - title: 'Statistics' - }) .state('app.administration', { abstract: true, url: 'administration', @@ -163,7 +158,16 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra url: '/case-templates', templateUrl: 'views/partials/admin/case-templates.html', controller: 'AdminCaseTemplatesCtrl', - title: 'Templates administration' + controllerAs: '$vm', + title: 'Templates administration', + resolve: { + templates: function(CaseTemplateSrv) { + return CaseTemplateSrv.list(); + }, + fields: function(CustomFieldsCacheSrv){ + return CustomFieldsCacheSrv.all() + } + } }) .state('app.administration.report-templates', { url: '/report-templates', @@ -283,6 +287,46 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra templateUrl: 'views/partials/alert/list.html', controller: 'AlertListCtrl', controllerAs: '$vm' + }) + .state('app.dashboards', { + url: 'dashboards', + templateUrl: 'views/partials/dashboard/list.html', + controller: 'DashboardsCtrl', + controllerAs: '$vm' + }) + .state('app.dashboards-view', { + url: 'dashboards/{id}', + templateUrl: 'views/partials/dashboard/view.html', + controller: 'DashboardViewCtrl', + controllerAs: '$vm', + resolve: { + dashboard: function(NotificationSrv, DashboardSrv, $stateParams, $q) { + var defer = $q.defer(); + + DashboardSrv.get($stateParams.id) + .then(function(response) { + defer.resolve(response.data); + }, function(err) { + NotificationSrv.error('DashboardViewCtrl', err.data, err.status); + defer.reject(err); + }); + + return defer.promise; + }, + metadata: function($q, DashboardSrv, NotificationSrv) { + var defer = $q.defer(); + + DashboardSrv.getMetadata() + .then(function(response) { + defer.resolve(response); + }, function(err) { + NotificationSrv.error('DashboardViewCtrl', err.data, err.status); + defer.reject(err); + }); + + return defer.promise; + } + } }); }) .config(function($httpProvider) { @@ -333,7 +377,7 @@ angular.module('thehive', ['ngAnimate', 'ngMessages', 'ngSanitize', 'ui.bootstra verticalSpacing: 20, horizontalSpacing: 20, positionX: 'left', - positionY: 'top' + positionY: 'bottom' }); }) .config(['markedProvider', 'hljsServiceProvider', function(markedProvider, hljsServiceProvider) { diff --git a/ui/app/scripts/controllers/AboutCtrl.js b/ui/app/scripts/controllers/AboutCtrl.js index f982964f28..65848df41a 100644 --- a/ui/app/scripts/controllers/AboutCtrl.js +++ b/ui/app/scripts/controllers/AboutCtrl.js @@ -8,6 +8,7 @@ function($rootScope, $scope, $uibModalInstance, VersionSrv, NotificationSrv) { VersionSrv.get().then(function(response) { $scope.version = response.versions; + $scope.connectors = response.connectors; }, function(data, status) { NotificationSrv.error('AboutCtrl', data, status); }); diff --git a/ui/app/scripts/controllers/RootCtrl.js b/ui/app/scripts/controllers/RootCtrl.js index d311b74aa3..13b02a8d99 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, AuthenticationSrv, AlertingSrv, StreamSrv, StreamStatSrv, TemplateSrv, CustomFieldsCacheSrv, MetricsCacheSrv, NotificationSrv, AppLayoutSrv, currentUser, appConfig) { + function($scope, $rootScope, $uibModal, $location, $state, AuthenticationSrv, AlertingSrv, StreamSrv, StreamStatSrv, CaseTemplateSrv, CustomFieldsCacheSrv, MetricsCacheSrv, NotificationSrv, AppLayoutSrv, currentUser, appConfig) { 'use strict'; if(currentUser === 520) { @@ -26,7 +26,9 @@ angular.module('theHiveControllers').controller('RootCtrl', StreamSrv.init(); $scope.currentUser = currentUser; - $scope.templates = TemplateSrv.query(); + CaseTemplateSrv.list().then(function(templates) { + $scope.templates = templates; + }); $scope.myCurrentTasks = StreamStatSrv({ scope: $scope, @@ -62,8 +64,10 @@ angular.module('theHiveControllers').controller('RootCtrl', // Get Alert counts $scope.alertEvents = AlertingSrv.stats($scope); - $scope.$on('templates:refresh', function(){ - $scope.templates = TemplateSrv.query(); + $scope.$on('templates:refresh', function(){ + CaseTemplateSrv.list().then(function(templates) { + $scope.templates = templates; + }); }); $scope.$on('metrics:refresh', function() { diff --git a/ui/app/scripts/controllers/StatisticsCtrl.js b/ui/app/scripts/controllers/StatisticsCtrl.js deleted file mode 100644 index 273dcf6ea6..0000000000 --- a/ui/app/scripts/controllers/StatisticsCtrl.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Controller for statistics page - */ -(function() { - 'use strict'; - - angular.module('theHiveControllers').controller('StatisticsCtrl', function($scope, $rootScope, $timeout, StatisticSrv) { - $scope.globalFilters = StatisticSrv.getFilters() || { - fromDate: moment().subtract(30, 'd').toDate(), - toDate: moment().toDate(), - tags: [], - tagsAggregator: 'any' - }; - - $scope.tagsAggregators = { - any: 'Any of', - all: 'All of', - none: 'None of' - }; - - $scope.caseByTlp = { - title: 'Cases by TLP', - type: 'case', - field: 'tlp', - dateField: 'startDate', - tagsField: 'tags', - colors: { - '0': '#cccccc', - '1': '#5cb85c', - '2': '#f0ad4e', - '3': '#d9534f' - }, - names: { - '0': 'White', - '1': 'Green', - '2': 'Amber', - '3': 'Red' - } - }; - - $scope.caseBySeverity = { - title: 'Cases by Severity', - type: 'case', - field: 'severity', - dateField: 'startDate', - tagsField: 'tags', - colors: { - '1': '#5bc0de', - '2': '#f0ad4e', - '3': '#d9534f' - }, - names: { - '1': 'Low', - '2': 'Medium', - '3': 'High' - } - }; - - $scope.caseByStatus = { - title: 'Cases by status', - type: 'case', - field: 'status', - dateField: 'startDate', - tagsField: 'tags' - }; - - $scope.caseByResolution = { - title: 'Resolved cases by resolution', - type: 'case', - field: 'resolutionStatus', - dateField: 'startDate', - tagsField: 'tags', - filter: {status: 'Resolved'} - }; - - $scope.caseOverTime = { - title: 'Cases over time', - type: 'case', - fields: ['startDate', 'endDate'], - dateField: 'startDate', - tagsField: 'tags', - names: { - startDate: 'Number of open cases', - endDate: 'Number of resolved cases' - }, - types: { - startDate: 'bar' - } - }; - - $scope.caseHandlingDurationOverTime = { - title: 'Cases handling over time', - dateField: 'startDate', - tagsField: 'tags', - names: { - max: 'Max', - min: 'Min', - avg: 'Avg', - count: 'Number of resolved cases' - }, - types: { - count: 'bar' - }, - axes: { - count: 'y2' - }, - colors: { - 'count': '#ff7f0e', - 'max': '#d62728', - 'min': '#2ca02c', - 'avg': '#1f77b4' - }, - filter: {status: 'Resolved'} - }; - - $scope.caseMetricsOverTime = { - title: 'Case metrics over time', - entity: 'case', - type: 'line', - field: 'startDate', - dateField: 'startDate', - tagsField: 'tags', - aggregations: ['sum'] - }; - - $scope.observableByDataType = { - title: 'Observables by Type', - type: 'case_artifact', - field: 'dataType', - dateField: 'startDate', - tagsField: 'tags', - filter: {status: 'Ok'} - }; - - $scope.observableByIoc = { - title: 'Observables by IOC flag', - type: 'case_artifact', - field: 'ioc', - dateField: 'startDate', - tagsField: 'tags', - names: { - 'false': 'NOT IOC', - 'true': 'IOC' - }, - filter: {status: 'Ok'} - }; - - $scope.observableOverTime = { - title: 'Observables over time', - type: 'case/artifact', - fields: ['startDate'], - dateField: 'startDate', - tagsField: 'tags', - names: { - startDate: 'Number of observables' - }, - types: { - startDate: 'bar' - }, - filter: {status: 'Ok'} - }; - - $scope.setTagsAggregator = function(aggregator) { - $scope.globalFilters.tagsAggregator = aggregator; - }; - - - // Prepare the global query - $scope.prepareGlobalQuery = function() { - // Handle date queries - var start = $scope.globalFilters.fromDate ? $scope.globalFilters.fromDate.getTime() : '*'; - var end = $scope.globalFilters.toDate ? $scope.globalFilters.toDate.setHours(23,59,59,999) : '*'; - - // Handle tags query - var tags = _.map($scope.globalFilters.tags, function(tag) { - return tag.text; - }); - - return function(options) { - var queryCriteria = { - _and: [ - { - _between: { _field: options.dateField, _from: start, _to: end} - } - ] - }; - - // 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() { - StatisticSrv.setFilters($scope.globalFilters); - - $rootScope.$broadcast('refresh-charts', $scope.prepareGlobalQuery()); - }; - - $timeout(function(){ - $scope.filter(); - }, 500); - - - }); - -})(); diff --git a/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js b/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js index 4d85ae349a..a17747643c 100644 --- a/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminCaseTemplatesCtrl.js @@ -2,14 +2,18 @@ 'use strict'; angular.module('theHiveControllers').controller('AdminCaseTemplatesCtrl', - function($scope, $uibModal, TemplateSrv, NotificationSrv, UtilsSrv, ListSrv, MetricsCacheSrv, CustomFieldsCacheSrv) { - $scope.task = ''; - $scope.tags = []; - $scope.templates = []; - $scope.metrics = []; - $scope.fields = []; - $scope.templateCustomFields = []; - $scope.templateIndex = -1; + function($scope, $uibModal, CaseTemplateSrv, NotificationSrv, UtilsSrv, ListSrv, MetricsCacheSrv, CustomFieldsCacheSrv, UserSrv, UserInfoSrv, ModalUtilsSrv, templates, fields) { + var self = this; + + self.templates = templates; + self.task = ''; + self.tags = []; + self.metrics = []; + self.fields = fields || []; + self.templateCustomFields = []; + self.templateMetrics = []; + self.templateIndex = -1; + self.getUserInfo = UserInfoSrv; /** * Convert the template custom fields definition to a list of ordered field names @@ -18,121 +22,148 @@ var getTemplateCustomFields = function(customFields) { var result = []; - result = _.pluck(_.sortBy(_.map(customFields, function(definition, name){ + result = _.sortBy(_.map(customFields, function(definition, name){ return { name: name, - order: definition.order + order: definition.order, + value: definition[self.fields[name].type] } }), function(item){ return item.order; - }), 'name'); + }); return result; } - $scope.sortableOptions = { + var getTemplateMetrics = function(metrics) { + var result = []; + + _.each(metrics, function(value, key) { + result.push({ + metric: key, + value: value + }); + }); + + return result; + } + + self.dateOptions = { + 'closeOnDateSelection': true, + formatYear: 'yyyy', + startingDay: 1 + }; + + self.sortableOptions = { handle: '.drag-handle', stop: function(/*e, ui*/) { - $scope.reorderTasks(); + self.reorderTasks(); }, axis: 'y' }; - $scope.sortableFields = { + self.sortableFields = { handle: '.drag-handle', axis: 'y' }; - $scope.keys = function(obj) { + self.keys = function(obj) { if(!obj) { return []; } return _.keys(obj); }; - $scope.loadCache = function() { + self.loadCache = function() { MetricsCacheSrv.all().then(function(metrics){ - $scope.metrics = metrics; - }); - - CustomFieldsCacheSrv.all().then(function(fields){ - $scope.fields = fields; + self.metrics = metrics; }); }; - $scope.loadCache(); + self.loadCache(); - $scope.getList = function(index) { - TemplateSrv.query(function(templates) { - $scope.templates = templates; - $scope.templateIndex = index; + self.getList = function(id) { + CaseTemplateSrv.list().then(function(templates) { + self.templates = templates; - if(templates.length > 0) { - $scope.loadTemplate(templates[index].id, $scope.templateIndex); + if(templates.length === 0) { + self.templateIndex = 0; + self.newTemplate(); + } else if(id){ + self.loadTemplateById(id); } else { - $scope.newTemplate(); + self.loadTemplateById(templates[0].id, 0); } }); }; - $scope.getList(0); - $scope.loadTemplate = function(id, index) { - TemplateSrv.get({ - templateId: id - }, function(template) { - delete template.createdAt; - delete template.createdBy; - delete template.updatedAt; - delete template.updatedBy; + self.loadTemplate = function(template, index) { + if(!template) { + return; + } - $scope.template = template; - $scope.tags = UtilsSrv.objectify($scope.template.tags, 'text'); + self.template = _.omit(template, + '_type', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy'); + self.tags = UtilsSrv.objectify(self.template.tags, 'text'); + self.templateCustomFields = getTemplateCustomFields(template.customFields); + self.templateMetrics = getTemplateMetrics(template.metrics); + + self.templateIndex = index || _.indexOf(self.templates, _.findWhere(self.templates, {id: template.id})); + } - $scope.templateCustomFields = getTemplateCustomFields(template.customFields); - }); + self.loadTemplate(self.templates[0]); - $scope.templateIndex = index; + self.loadTemplateById = function(id) { + CaseTemplateSrv.get(id) + .then(function(template) { + self.loadTemplate(template); + }); }; - $scope.newTemplate = function() { - $scope.template = { + self.newTemplate = function() { + self.template = { name: '', titlePrefix: '', severity: 2, tlp: 2, tags: [], tasks: [], - metricNames: [], + metrics: {}, customFields: {}, description: '' }; - $scope.tags = []; - $scope.templateIndex = -1; - $scope.templateCustomFields = []; + self.tags = []; + self.templateIndex = -1; + self.templateCustomFields = []; + self.templateMetrics = []; }; - $scope.reorderTasks = function() { - _.each($scope.template.tasks, function(task, index) { + self.reorderTasks = function() { + _.each(self.template.tasks, function(task, index) { task.order = index; }); }; - $scope.removeTask = function(task) { - $scope.template.tasks = _.without($scope.template.tasks, task); - $scope.reorderTasks(); + self.removeTask = function(task) { + self.template.tasks = _.without(self.template.tasks, task); + self.reorderTasks(); }; - $scope.addTask = function() { - var order = $scope.template.tasks ? $scope.template.tasks.length : 0; + self.addTask = function() { + var order = self.template.tasks ? self.template.tasks.length : 0; - $scope.openTaskDialog({order: order}, 'Add'); + self.openTaskDialog({order: order}, 'Add'); }; - $scope.editTask = function(task) { - $scope.openTaskDialog(task, 'Update'); + self.editTask = function(task) { + self.openTaskDialog(task, 'Update'); }; - $scope.openTaskDialog = function(task, action) { - $uibModal.open({ + self.openTaskDialog = function(task, action) { + var modal = $uibModal.open({ scope: $scope, templateUrl: 'views/partials/admin/case-templates.task.html', controller: 'AdminCaseTemplateTasksCtrl', @@ -142,131 +173,232 @@ return action; }, task: function() { - return task; + return _.extend({}, task); + }, + users: function() { + return UserSrv.list({status: 'Ok'}); } } }); + + modal.result.then(function(data) { + if(action === 'Add') { + if(self.template.tasks) { + self.template.tasks.push(data); + } else { + self.template.tasks = [data]; + } + } else { + self.template.tasks[data.order] = data; + } + }); }; - $scope.addMetric = function(metric) { - var metrics = $scope.template.metricNames || []; + self.addMetric = function(metric) { + self.template.metrics = self.template.metrics || {}; + self.template.metrics[metric.name] = null; + }; - if(metrics.indexOf(metric.name) === -1) { - metrics.push(metric.name); - $scope.template.metricNames = metrics; - } else { - NotificationSrv.log('The metric [' + metric.title + '] has already been added to the template', 'warning'); - } + self.addMetricRow = function() { + self.templateMetrics.push({ + metric: null, + value: null + }); }; - $scope.removeMetric = function(metricName) { - $scope.template.metricNames = _.without($scope.template.metricNames, metricName); + self.removeMetric = function(metric) { + self.templateMetrics = _.without(self.templateMetrics, metric); + //delete self.template.metrics[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'); - } + self.addCustomFieldRow = function() { + self.templateCustomFields.push({ + name: null, + order: self.templateCustomFields.length + 1, + value: null + }); }; - $scope.removeCustomField = function(fieldName) { - $scope.templateCustomFields = _.without($scope.templateCustomFields, fieldName); + self.removeCustomField = function(field) { + self.templateCustomFields = _.without(self.templateCustomFields, field); }; - $scope.deleteTemplate = function() { - $uibModal.open({ - scope: $scope, - templateUrl: 'views/partials/admin/case-templates.delete.html', - controller: 'AdminCaseTemplateDeleteCtrl', - size: '' + self.updateCustomField = function(field, value) { + field.value = value; + }; + + self.deleteTemplate = function() { + ModalUtilsSrv.confirm('Remove case template', 'Are you sure you want to delete this case template?', { + okText: 'Yes, remove it', + flavor: 'danger' + }).then(function() { + return CaseTemplateSrv.delete(self.template.id); + }).then(function() { + self.getList(); + + $scope.$emit('templates:refresh'); }); }; - $scope.saveTemplate = function() { + self.saveTemplate = function() { // Set tags - $scope.template.tags = _.pluck($scope.tags, 'text'); + self.template.tags = _.pluck(self.tags, 'text'); // Set custom fields - $scope.template.customFields = {}; - _.each($scope.templateCustomFields, function(value, index) { - var fieldDef = $scope.fields[value]; + self.template.customFields = {}; + _.each(self.templateCustomFields, function(cf, index) { + var fieldDef = self.fields[cf.name]; + var value = (fieldDef.type === 'date' && cf.value) ? moment(cf.value).valueOf() : (cf.value || null) + + self.template.customFields[cf.name] = {}; + self.template.customFields[cf.name][fieldDef.type] = value; + self.template.customFields[cf.name].order = index + 1; + }); + + self.template.metrics = {}; + _.each(self.templateMetrics, function(value, index) { + var fieldDef = self.fields[value]; - $scope.template.customFields[value] = {}; - $scope.template.customFields[value][fieldDef.type] = null; - $scope.template.customFields[value].order = index + 1; + self.template.metrics[value.metric] = value.value; }); - if (_.isEmpty($scope.template.id)) { - $scope.createTemplate(); + if (_.isEmpty(self.template.id)) { + self.createTemplate(self.template); } else { - $scope.updateTemplate(); + self.updateTemplate(self.template); } }; - $scope.createTemplate = function() { - return TemplateSrv.save($scope.template, function() { - $scope.getList(0); + self.createTemplate = function(template) { + return CaseTemplateSrv.create(template) + .then(function(response) { + self.getList(response.data.id); - $scope.$emit('templates:refresh'); + $scope.$emit('templates:refresh'); - NotificationSrv.log('The template [' + $scope.template.name + '] has been successfully created', 'success'); - }, function(response) { - NotificationSrv.error('TemplateCtrl', response.data, response.status); - }); + NotificationSrv.log('The template [' + template.name + '] has been successfully created', 'success'); + }, function(response) { + NotificationSrv.error('TemplateCtrl', response.data, response.status); + }); }; - $scope.updateTemplate = function() { - return TemplateSrv.update({ - templateId: $scope.template.id - }, _.omit($scope.template, ['id', 'user', '_type']), function() { - $scope.getList($scope.templateIndex); + self.updateTemplate = function(template) { + return CaseTemplateSrv.update(template.id, _.omit(template, ['id', 'user', '_type'])) + .then(function(response) { + self.getList(template.id); - $scope.$emit('templates:refresh'); + $scope.$emit('templates:refresh'); - NotificationSrv.log('The template [' + $scope.template.name + '] has been successfully updated', 'success'); - }, function(response) { - NotificationSrv.error('TemplateCtrl', response.data, response.status); - }); + NotificationSrv.log('The template [' + template.name + '] has been successfully updated', 'success'); + }, function(response) { + NotificationSrv.error('TemplateCtrl', response.data, response.status); + }); }; + self.exportTemplate = function() { + var fileName = 'Case-Template__' + self.template.name.replace(/\s/gi, '_') + '.json'; + + // Create a blob of the data + var fileToSave = new Blob([angular.toJson(_.omit(self.template, 'id'))], { + type: 'application/json', + name: fileName + }); + + // Save the file + saveAs(fileToSave, fileName); + } + + self.importTemplate = function() { + var modalInstance = $uibModal.open({ + animation: true, + templateUrl: 'views/partials/admin/case-template/import.html', + controller: 'AdminCaseTemplateImportCtrl', + controllerAs: 'vm', + size: 'lg' + }); + + modalInstance.result.then(function(template) { + return self.createTemplate(template); + }) + .then(function(response) { + self.getList(response.data.id); + + NotificationSrv.log('The template has been successfully imported', 'success'); + }) + .catch(function(err) { + if (err && err.status) { + NotificationSrv.error('TemplateCtrl', err.data, err.status); + } + }); + } + + // this.duplicateTemplate = function(template) { + // var copy = _.pick(template, 'name', 'title', 'description', 'tlp', 'severity', 'tags', 'status', 'titlePrefix', 'tasks', 'metrics', 'customFields'); + // copy.name = 'Copy_of_' + copy.name; + // + // this.openDashboardModal(copy) + // .result.then(function(dashboard) { + // return DashboardSrv.create(dashboard); + // }) + // .then(function(response) { + // $state.go('app.dashboards-view', {id: response.data.id}); + // + // NotificationSrv.log('The dashboard has been successfully created', 'success'); + // }) + // .catch(function(err) { + // if (err && err.status) { + // NotificationSrv.error('DashboardsCtrl', err.data, err.status); + // } + // }); + // }; + }) - .controller('AdminCaseTemplateTasksCtrl', function($scope, $uibModalInstance, action, task) { + .controller('AdminCaseTemplateTasksCtrl', function($scope, $uibModalInstance, action, task, users) { $scope.task = task || {}; $scope.action = action; + $scope.users = users; $scope.cancel = function() { $uibModalInstance.dismiss(); }; $scope.addTask = function() { - if(action === 'Add') { - if($scope.template.tasks) { - $scope.template.tasks.push(task); - } else { - $scope.template.tasks = [task]; - } - } - - $uibModalInstance.dismiss(); + $uibModalInstance.close(task); }; }) - .controller('AdminCaseTemplateDeleteCtrl', function($scope, $uibModalInstance, TemplateSrv) { - $scope.cancel = function() { - $uibModalInstance.dismiss(); + .controller('AdminCaseTemplateImportCtrl', function($scope, $uibModalInstance) { + var self = this; + this.formData = { + fileContent: {} }; - $scope.confirm = function() { - TemplateSrv.delete({ - templateId: $scope.template.id - }, function() { - $scope.getList(0); + $scope.$watch('vm.formData.attachment', function(file) { + if(!file) { + self.formData.fileContent = {}; + return; + } + var aReader = new FileReader(); + aReader.readAsText(self.formData.attachment, 'UTF-8'); + aReader.onload = function (evt) { + $scope.$apply(function() { + self.formData.fileContent = JSON.parse(aReader.result); + }); + } + aReader.onerror = function (evt) { + $scope.$apply(function() { + self.formData.fileContent = {}; + }); + } + }); - $scope.$emit('templates:refresh'); + this.ok = function () { + var template = _.pick(this.formData.fileContent, 'name', 'title', 'description', 'tlp', 'severity', 'tags', 'status', 'titlePrefix', 'tasks', 'metrics', 'customFields'); + $uibModalInstance.close(template); + }; - $uibModalInstance.dismiss(); - }); + this.cancel = function () { + $uibModalInstance.dismiss('cancel'); }; }); + })(); diff --git a/ui/app/scripts/controllers/admin/AdminUserDialogCtrl.js b/ui/app/scripts/controllers/admin/AdminUserDialogCtrl.js index f4c234e0d2..ebb0022ec8 100644 --- a/ui/app/scripts/controllers/admin/AdminUserDialogCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminUserDialogCtrl.js @@ -1,11 +1,11 @@ (function() { 'use strict'; - angular.module('theHiveControllers').controller('AdminUserDialogCtrl', function($scope, $uibModalInstance, UserSrv, NotificationSrv, user) { + angular.module('theHiveControllers').controller('AdminUserDialogCtrl', function($scope, $uibModalInstance, UserSrv, NotificationSrv, user, isEdit) { var self = this; self.user = user; - self.isEdit = user.id; + self.isEdit = isEdit; var formData = _.defaults(_.pick(self.user, 'id', 'name', 'roles'), { id: null, diff --git a/ui/app/scripts/controllers/admin/AdminUsersCtrl.js b/ui/app/scripts/controllers/admin/AdminUsersCtrl.js index 3c0a0e8182..20c2d0765e 100644 --- a/ui/app/scripts/controllers/admin/AdminUsersCtrl.js +++ b/ui/app/scripts/controllers/admin/AdminUsersCtrl.js @@ -13,7 +13,8 @@ * users management page */ $scope.userlist = PSearchSrv(undefined, 'user', { - scope: $scope + scope: $scope, + sort: '+name', }); $scope.initNewUser = function() { $scope.apiKey = false; @@ -113,7 +114,8 @@ controllerAs: '$vm', size: 'lg', resolve: { - user: angular.copy(user) || {} + user: angular.copy(user) || {}, + isEdit: !!user } }); diff --git a/ui/app/scripts/controllers/alert/AlertListCtrl.js b/ui/app/scripts/controllers/alert/AlertListCtrl.js index 843b956fbc..97ae975370 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, TagSrv, TemplateSrv, AlertingSrv, NotificationSrv, FilteringSrv, Severity) { + .controller('AlertListCtrl', function($scope, $q, $state, $uibModal, TagSrv, CaseTemplateSrv, AlertingSrv, NotificationSrv, FilteringSrv, Severity) { var self = this; self.list = []; @@ -193,7 +193,7 @@ resolve: { event: event, templates: function() { - return TemplateSrv.query().$promise; + return CaseTemplateSrv.list(); } } }); diff --git a/ui/app/scripts/controllers/case/CaseExportDialogCtrl.js b/ui/app/scripts/controllers/case/CaseExportDialogCtrl.js index eeaaa4c4e6..f5eae82348 100644 --- a/ui/app/scripts/controllers/case/CaseExportDialogCtrl.js +++ b/ui/app/scripts/controllers/case/CaseExportDialogCtrl.js @@ -8,7 +8,7 @@ this.caze = caze; this.mode = ''; - this.servers = config.servers; + this.servers = config.servers; // TODO Nabil this.failures = []; this.existingExports = {}; diff --git a/ui/app/scripts/controllers/case/CaseMainCtrl.js b/ui/app/scripts/controllers/case/CaseMainCtrl.js index dc97fff5a8..12ae431220 100644 --- a/ui/app/scripts/controllers/case/CaseMainCtrl.js +++ b/ui/app/scripts/controllers/case/CaseMainCtrl.js @@ -206,6 +206,11 @@ }; $scope.shareCase = function() { + if($scope.appConfig.connectors.misp && $scope.appConfig.connectors.misp.servers.length === 0) { + NotificationSrv.log('There are no MISP servers defined', 'error'); + return; + } + var modalInstance = $uibModal.open({ templateUrl: 'views/partials/misp/case.export.confirm.html', controller: 'CaseExportDialogCtrl', diff --git a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js index c629ec25c3..32dbb87d01 100644 --- a/ui/app/scripts/controllers/case/CaseObservablesCtrl.js +++ b/ui/app/scripts/controllers/case/CaseObservablesCtrl.js @@ -423,18 +423,27 @@ $scope.chTLP = '-1'; $scope.updateTLP = function (value) { $scope.chTLP = value; - angular.forEach($scope.selection.artifacts, function (te) { - $scope.updateField(te.id, 'tlp', $scope.chTLP); - }); - $scope.chTLP = '-1'; + CaseArtifactSrv.bulkUpdate(_.pluck($scope.selection.artifacts, 'id'), {'tlp': $scope.chTLP}) + .then(function(){ + $scope.chTLP = '-1'; + NotificationSrv.log('Selected observables have been updated successfully', 'success'); + $scope.selection.Action='main'; + }); }; - $scope.setIOC = function (action) { - var ioc = action === 'setIocFlog'; - - angular.forEach($scope.selection.artifacts, function (te) { - $scope.updateField(te.id, 'ioc', ioc); - }); + $scope.setIOC = function (ioc) { + CaseArtifactSrv.bulkUpdate(_.pluck($scope.selection.artifacts, 'id'), {ioc: ioc}) + .then(function(){ + NotificationSrv.log('Selected observables have been updated successfully', 'success'); + $scope.selection.Action='main'; + }); + }; + $scope.setSightedFlag = function (sighted) { + CaseArtifactSrv.bulkUpdate(_.pluck($scope.selection.artifacts, 'id'), {sighted: sighted}) + .then(function(){ + NotificationSrv.log('Selected observables have been updated successfully', 'success'); + $scope.selection.Action='main'; + }); }; $scope.updateField = function (id, fieldName, newValue) { diff --git a/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js b/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js index 9b2fbb617d..b4f2ad3076 100644 --- a/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js +++ b/ui/app/scripts/controllers/case/CaseObservablesItemCtrl.js @@ -20,6 +20,7 @@ $scope.artifact = {}; $scope.artifact.tlp = $scope.artifact.tlp || -1; $scope.analysisEnabled = VersionSrv.hasCortex(); + $scope.cortexServers = $scope.analysisEnabled && appConfig.connectors.cortex.servers; $scope.protectDownloadsWith = appConfig.config.protectDownloadsWith; $scope.editorOptions = { @@ -80,7 +81,7 @@ _.each(_.keys($scope.analyzers).sort(), function(analyzerId) { $scope.analyzerJobs[analyzerId] = []; - }); + }); angular.forEach($scope.jobs.values, function (job) { if (job.analyzerId in $scope.analyzerJobs) { @@ -180,11 +181,12 @@ }); }; - $scope.runAnalyzer = function (analyzerId) { + $scope.runAnalyzer = function (analyzerId, serverId) { var artifactName = $scope.artifact.data || $scope.artifact.attachment.name; - CortexSrv.getServers([analyzerId]) - .then(function (serverId) { + var promise = serverId ? $q.resolve(serverId) : CortexSrv.getServers([analyzerId]) + + promise.then(function (serverId) { return $scope._runAnalyzer(serverId, analyzerId, $scope.artifact.id); }) .then(function () { diff --git a/ui/app/scripts/controllers/case/ObservableCreationCtrl.js b/ui/app/scripts/controllers/case/ObservableCreationCtrl.js index 41665bfecc..9b2cc8aca4 100644 --- a/ui/app/scripts/controllers/case/ObservableCreationCtrl.js +++ b/ui/app/scripts/controllers/case/ObservableCreationCtrl.js @@ -13,6 +13,7 @@ $scope.params = { bulk: false, ioc: false, + sighted: false, data: '', tlp: 2, message: '', @@ -71,6 +72,7 @@ postData = { dataType: params.dataType, ioc: params.ioc, + sighted: params.sighted, tlp: params.tlp, message: params.message, tags: _.unique(_.pluck($scope.tags, 'text')) diff --git a/ui/app/scripts/controllers/dashboard/DashboardViewCtrl.js b/ui/app/scripts/controllers/dashboard/DashboardViewCtrl.js new file mode 100644 index 0000000000..2b11c77b0f --- /dev/null +++ b/ui/app/scripts/controllers/dashboard/DashboardViewCtrl.js @@ -0,0 +1,170 @@ +(function() { + 'use strict'; + + angular + .module('theHiveControllers') + .controller('DashboardViewCtrl', function($scope, $q, $timeout, $uibModal, AuthenticationSrv, DashboardSrv, NotificationSrv, ModalUtilsSrv, UtilsSrv, dashboard, metadata) { + var self = this; + + this.currentUser = AuthenticationSrv.currentUser; + this.createdBy = dashboard.createdBy; + this.dashboardStatus = dashboard.dashboardStatus; + this.metadata = metadata; + this.toolbox = DashboardSrv.toolbox; + this.dashboardPeriods = DashboardSrv.dashboardPeriods; + + this.buildDashboardPeriodFilter = function(period) { + return period === 'custom' ? + DashboardSrv.buildPeriodQuery(period, 'createdAt', this.definition.customPeriod.fromDate, this.definition.customPeriod.toDate) : + DashboardSrv.buildPeriodQuery(period, 'createdAt'); + } + + this.loadDashboard = function(dashboard) { + this.dashboard = dashboard; + this.definition = JSON.parse(dashboard.definition) || { + period: 'all', + items: [ + { + type: 'container', + items: [] + } + ] + }; + this.periodFilter = this.buildDashboardPeriodFilter(this.definition.period); + } + + this.loadDashboard(dashboard); + + this.canEditDashboard = function() { + return (this.createdBy === this.currentUser.id) || + (this.dashboardStatus = 'Shared' && AuthenticationSrv.isAdmin(this.currentUser)); + } + + this.options = { + dashboardAllowedTypes: ['container'], + containerAllowedTypes: ['bar', 'line', 'donut', 'counter'], + maxColumns: 3, + cls: DashboardSrv.typeClasses, + labels: { + container: 'Row', + bar: 'Bar', + donut: 'Donut', + line: 'Line', + counter: 'Counter' + }, + editLayout: !_.find(this.definition.items, function(row) { + return row.items.length > 0; + }) && this.canEditDashboard() + }; + + this.applyPeriod = function(period) { + this.definition.period = period; + this.periodFilter = this.buildDashboardPeriodFilter(period); + + $scope.$broadcast('refresh-chart', this.periodFilter); + } + + this.removeContainer = function(index) { + var row = this.definition.items[index]; + + var promise; + if(row.items.length === 0) { + // If the container is empty, don't ask for confirmation + promise = $q.resolve(); + } else { + promise = ModalUtilsSrv.confirm('Remove widget', 'Are you sure you want to remove this item', { + okText: 'Yes, remove it', + flavor: 'danger' + }) + } + + promise.then(function() { + self.definition.items.splice(index, 1) + }); + } + + this.saveDashboard = function() { + var copy = _.pick(this.dashboard, 'title', 'description', 'status'); + copy.definition = angular.toJson(this.definition); + + DashboardSrv.update(this.dashboard.id, copy) + .then(function(response) { + self.options.editLayout = false; + self.resizeCharts(); + NotificationSrv.log('The dashboard has been successfully updated', 'success'); + }) + .catch(function(err) { + NotificationSrv.error('DashboardEditCtrl', err.data, err.status); + }) + } + + this.removeItem = function(rowIndex, colIndex) { + + ModalUtilsSrv.confirm('Remove widget', 'Are you sure you want to remove this item', { + okText: 'Yes, remove it', + flavor: 'danger' + }).then(function() { + var row = self.definition.items[rowIndex]; + row.items.splice(colIndex, 1); + + $timeout(function() { + $scope.$broadcast('resize-chart-' + rowIndex); + }, 0); + }); + + } + + this.itemInserted = function(item, rows, rowIndex, index) { + if(!item.id){ + item.id = UtilsSrv.guid(); + } + + for(var i=0; i < rows.length; i++) { + $scope.$broadcast('resize-chart-' + i); + } + + if (this.options.containerAllowedTypes.indexOf(item.type) !== -1 && !item.options.entity) { + // The item is a widget + $timeout(function() { + $scope.$broadcast('edit-chart-' + item.id); + }, 0); + } + + return item; + } + + this.itemDragStarted = function(colIndex, row) { + row.items.splice(colIndex, 1); + } + + this.exportDashboard = function() { + DashboardSrv.exportDashboard(this.dashboard); + } + + this.resizeCharts = function() { + $timeout(function() { + for(var i=0; i < self.definition.items.length; i++) { + $scope.$broadcast('resize-chart-' + i); + } + }, 100); + }; + + this.enableEditMode = function() { + this.options.editLayout = true; + this.resizeCharts(); + }; + + this.enableViewMode = function() { + DashboardSrv.get(this.dashboard.id) + .then(function(response) { + self.loadDashboard(response.data); + self.options.editLayout = false; + self.resizeCharts(); + }, function(err) { + NotificationSrv.error('DashboardViewCtrl', err.data, err.status); + }); + }; + + + }); +})(); diff --git a/ui/app/scripts/controllers/dashboard/DashboardsCtrl.js b/ui/app/scripts/controllers/dashboard/DashboardsCtrl.js new file mode 100644 index 0000000000..53fa3a851a --- /dev/null +++ b/ui/app/scripts/controllers/dashboard/DashboardsCtrl.js @@ -0,0 +1,190 @@ +(function() { + 'use strict'; + + angular + .module('theHiveControllers') + .controller('DashboardImportCtrl', function($scope, $uibModalInstance) { + var self = this; + this.formData = { + fileContent: {} + }; + + $scope.$watch('vm.formData.attachment', function(file) { + if(!file) { + self.formData.fileContent = {}; + return; + } + var aReader = new FileReader(); + aReader.readAsText(self.formData.attachment, 'UTF-8'); + aReader.onload = function (evt) { + $scope.$apply(function() { + self.formData.fileContent = JSON.parse(aReader.result); + }); + } + aReader.onerror = function (evt) { + $scope.$apply(function() { + self.formData.fileContent = {}; + }); + } + }); + + this.ok = function () { + var dashboard = _.pick(this.formData.fileContent, 'title', 'description', 'status'); + dashboard.definition = JSON.stringify(this.formData.fileContent.definition || {}); + + $uibModalInstance.close(dashboard); + }; + + this.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + }) + .controller('DashboardModalCtrl', function($uibModalInstance, $state, statuses, dashboard) { + this.dashboard = dashboard; + this.statuses = statuses; + + this.cancel = function() { + $uibModalInstance.dismiss(); + }; + + this.ok = function() { + return $uibModalInstance.close(dashboard); + }; + }) + .controller('DashboardsCtrl', function($scope, $state, $uibModal, PSearchSrv, ModalUtilsSrv, NotificationSrv, DashboardSrv, AuthenticationSrv) { + this.dashboards = []; + var self = this; + + this.load = function() { + DashboardSrv.list().then(function(response) { + self.dashboards = response.data; + }, function(err){ + NotificationSrv.error('DashboardsCtrl', err.data, err.status); + }); + }; + + this.load(); + + this.openDashboardModal = function(dashboard) { + return $uibModal.open({ + templateUrl: 'views/partials/dashboard/create.dialog.html', + controller: 'DashboardModalCtrl', + controllerAs: '$vm', + size: 'lg', + resolve: { + statuses: function() { + return ['Private', 'Shared']; + }, + dashboard: function() { + return dashboard; + } + } + }); + }; + + this.addDashboard = function() { + var modalInstance = this.openDashboardModal({ + title: null, + description: null, + status: 'Private', + definition: JSON.stringify(DashboardSrv.defaultDashboard) + }); + + modalInstance.result + .then(function(dashboard) { + return DashboardSrv.create(dashboard); + }) + .then(function(response) { + $state.go('app.dashboards-view', {id: response.data.id}); + + NotificationSrv.log('The dashboard has been successfully created', 'success'); + }) + .catch(function(err) { + if (err && err.status) { + NotificationSrv.error('DashboardsCtrl', err.data, err.status); + } + }); + }; + + this.duplicateDashboard = function(dashboard) { + var copy = _.pick(dashboard, 'title', 'description', 'status', 'definition'); + copy.title = 'Copy of ' + copy.title; + + this.openDashboardModal(copy) + .result.then(function(dashboard) { + return DashboardSrv.create(dashboard); + }) + .then(function(response) { + $state.go('app.dashboards-view', {id: response.data.id}); + + NotificationSrv.log('The dashboard has been successfully created', 'success'); + }) + .catch(function(err) { + if (err && err.status) { + NotificationSrv.error('DashboardsCtrl', err.data, err.status); + } + }); + }; + + this.editDashboard = function(dashboard) { + var copy = _.extend({}, dashboard); + + this.openDashboardModal(copy).result.then(function(dashboard) { + return DashboardSrv.update(dashboard.id, _.omit(dashboard, 'id')); + }) + .then(function(response) { + self.load() + + NotificationSrv.log('The dashboard has been successfully updated', 'success'); + }) + .catch(function(err) { + if (err && err.status) { + NotificationSrv.error('DashboardsCtrl', err.data, err.status); + } + }); + }; + + this.deleteDashboard = function(id) { + ModalUtilsSrv.confirm('Remove dashboard', 'Are you sure you want to remove this dashboard', { + okText: 'Yes, remove it', + flavor: 'danger' + }) + .then(function() { + return DashboardSrv.remove(id); + }) + .then(function(response) { + self.load(); + + NotificationSrv.log('The dashboard has been successfully removed', 'success'); + }); + }; + + this.exportDashboard = function(dashboard) { + DashboardSrv.exportDashboard(dashboard); + } + + this.importDashboard = function() { + var modalInstance = $uibModal.open({ + animation: true, + templateUrl: 'views/partials/dashboard/import.dialog.html', + controller: 'DashboardImportCtrl', + controllerAs: 'vm', + size: 'lg' + }); + + modalInstance.result.then(function(dashboard) { + return DashboardSrv.create(dashboard); + }) + .then(function(response) { + $state.go('app.dashboards-view', {id: response.data.id}); + + NotificationSrv.log('The dashboard has been successfully imported', 'success'); + }) + .catch(function(err) { + if (err && err.status) { + NotificationSrv.error('DashboardsCtrl', err.data, err.status); + } + }); + } + }); +})(); diff --git a/ui/app/scripts/directives/affixer.js b/ui/app/scripts/directives/affixer.js new file mode 100644 index 0000000000..f5d373bab8 --- /dev/null +++ b/ui/app/scripts/directives/affixer.js @@ -0,0 +1,24 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives').directive('affixer', function($document, $window) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + var top = attrs.affixerOffset; + var topOffset = element[0].offsetTop - top; + + function affixElement() { + if ($window.pageYOffset > topOffset) { + element.css('position', 'fixed'); + element.css('top', top + 'px'); + } else { + element.css('position', ''); + element.css('top', ''); + } + } + + angular.element($window).bind('scroll', affixElement); + } + }; + }); +})(); diff --git a/ui/app/scripts/directives/charts/c3Chart.js b/ui/app/scripts/directives/charts/c3Chart.js index 3118c28ff3..92c296b7b2 100644 --- a/ui/app/scripts/directives/charts/c3Chart.js +++ b/ui/app/scripts/directives/charts/c3Chart.js @@ -1,10 +1,14 @@ (function() { 'use strict'; - angular.module('theHiveDirectives').directive('c3', function() { + angular.module('theHiveDirectives').directive('c3', function(DashboardSrv) { return { restrict: 'E', + replace: true, scope: { - chart: '=' + chart: '=', + resizeOn: '@', + error: '=', + onSaveCsv: '&?' }, templateUrl: 'views/directives/charts/c3.html', link: function(scope, element) { @@ -13,10 +17,13 @@ scope.initChart = function(chart) { if (!_.isEmpty(chart)) { scope.chart.bindto = binto; + scope.chart.color = { + pattern: DashboardSrv.colorsPattern + }; scope.chart.size = { height: 300 }; - c3.generate(scope.chart); + scope.c3 = c3.generate(scope.chart); } }; @@ -29,6 +36,14 @@ scope.$watch('chart', function(newValue) { scope.initChart(newValue); }); + + if(scope.resizeOn) { + scope.$on(scope.resizeOn, function() { + if(scope.c3) { + scope.c3.resize(); + } + }) + } } }; }); diff --git a/ui/app/scripts/directives/charts/chart.js b/ui/app/scripts/directives/charts/chart.js deleted file mode 100644 index c68da047f6..0000000000 --- a/ui/app/scripts/directives/charts/chart.js +++ /dev/null @@ -1,16 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveDirectives').directive('chart', function() { - return { - restrict: 'E', - scope: { - type: '@', - autoload: '=', - options: '=', - refreshOn: '@' - }, - templateUrl: 'views/directives/charts/chart.html' - }; - }); - -})(); diff --git a/ui/app/scripts/directives/charts/donut-chart.js b/ui/app/scripts/directives/charts/donut-chart.js deleted file mode 100644 index 6499acc58d..0000000000 --- a/ui/app/scripts/directives/charts/donut-chart.js +++ /dev/null @@ -1,97 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveDirectives').directive('donutChart', function(StatSrv, $state, NotificationSrv) { - return { - restrict: 'E', - scope: { - 'options': '=', - 'autoload': '=', - 'refreshOn': '@' - }, - templateUrl: 'views/directives/charts/donut-chart.html', - link: function(scope) { - scope.chart = {}; - - scope.buildQuery = function() { - var criteria = _.without([ - scope.options.filter, - scope.options.query - ], null, undefined, '', '*'); - - return criteria.length === 1 ? criteria[0] : {_and: criteria}; - }; - - scope.load = function() { - var query = scope.buildQuery(); - - var statConfig = { - query: query, - objectType: scope.options.type, - field: scope.options.field, - sort: scope.options.sort, - limit: scope.options.limit - }; - - StatSrv.getPromise(statConfig).then(function(response) { - - var keys = _.without(_.keys(response.data), 'count'); - var columns = keys.map(function(key) { - return [key, response.data[key].count]; - }); - - scope.chart = { - data: { - columns: columns, - type: 'donut', - names: scope.options.names || {}, - colors: scope.options.colors || {}, - onclick: function(d) { - var criteria = [ - { _type : scope.options.type }, - { _field: scope.options.field, _value: d.id} - ]; - - if (scope.options.query && scope.options.query !== '*') { - criteria.push(scope.options.query); - } - - var searchQuery = { - _and: criteria - }; - - $state.go('app.search', { - q: Base64.encode(angular.toJson(searchQuery)) - }); - } - }, - donut: { - title: 'Total: ' + response.data.count, - label: { - format: function(value) { - return value; - } - } - } - }; - - }, function(err) { - NotificationSrv.error('donutChart', err.data, err.status); - }); - }; - - if(scope.autoload === true) { - scope.load(); - } - - if (!_.isEmpty(scope.refreshOn)) { - scope.$on(scope.refreshOn, function(event, queryFn) { - scope.options.query = queryFn(scope.options); - scope.load(); - }); - } - } - - }; - }); - -})(); diff --git a/ui/app/scripts/directives/charts/duration-over-time-chart.js b/ui/app/scripts/directives/charts/duration-over-time-chart.js deleted file mode 100644 index a55ffe2967..0000000000 --- a/ui/app/scripts/directives/charts/duration-over-time-chart.js +++ /dev/null @@ -1,150 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveDirectives').directive('durationOverTimeChart', function($http, $interpolate, ChartSrv, NotificationSrv) { - return { - restrict: 'E', - scope: { - 'autoload': '=', - 'options': '=', - 'refreshOn': '@' - }, - templateUrl: 'views/directives/charts/duration-over-time-chart.html', - link: function(scope) { - scope.chart = {}; - scope.intervals = ChartSrv.timeIntervals; - scope.interval = scope.intervals[2]; - - scope.buildQuery = function() { - var criteria = _.without([ - scope.options.filter, - scope.options.query - ], null, undefined, '', '*'); - - return criteria.length === 1 ? criteria[0] : {_and: criteria}; - }; - - scope.load = function() { - var options = { - params: { - q: scope.buildQuery(), - duration: scope.interval.code - } - }; - - var computedFieldName = 'computed.handlingDuration'; - - $http.post('./api/case/_stats', { - query: options.params.q, - stats: [{ - _agg: 'time', - _fields: [scope.options.dateField], - _interval: options.params.duration, - _select: [{ - _agg: 'avg', - _field: computedFieldName - }, - { - _agg: 'min', - _field: computedFieldName - }, - { - _agg: 'max', - _field: computedFieldName - }, - { - _agg: 'count' - }] - }] - }).then(function(response) { - //var fieldNames = - - var labels = _.keys(response.data).map(function(d) { - return moment(d, 'YYYYMMDDTHHmmZZ').format('YYYY-MM-DD'); - }); - - var fn = function(value) { - return moment.duration(value).asDays(); - }; - - var humanDuration = function(value) { - var days = Math.round(value); - - if (days === 0) { - return '< 1 day'; - } - return days + ' day' + (days > 1 ? 's' : ''); - }; - - var data = _.pluck(_.values(response.data), scope.options.dateField); - - var count = _.pluck(data, 'count'); - var max = _.pluck(data, 'max_' + computedFieldName).map(fn); - var min = _.pluck(data, 'min_' + computedFieldName).map(fn); - var avg = _.pluck(data, 'avg_' + computedFieldName).map(fn); - - scope.chart = { - data: { - x: 'date', - columns: [ - ['date'].concat(labels), ['count'].concat(count), ['max'].concat(max), ['min'].concat(min), ['avg'].concat(avg) - ], - names: scope.options.names || {}, - type: 'line', - types: scope.options.types || {}, - axes: scope.options.axes || {}, - colors: scope.options.colors || {} - }, - bar: { - width: { - ratio: 0.1 - } - }, - axis: { - x: { - type: 'timeseries', - tick: { - format: '%Y-%m-%d', - rotate: 90 - } - }, - y2: { - show: true - } - }, - tooltip: { - format: { - value: function(value, ratio, id) { - if (['min', 'max', 'avg'].indexOf(id) !== -1) { - return humanDuration(value); - } - return value; - } - - } - }, - zoom: { - enabled: scope.zoom || false - } - }; - - }, function(err) { - NotificationSrv.error('durationOverTimeChart', err.data, err.status); - }); - }; - - if (scope.autoload === true) { - scope.load(); - } - - if (!_.isEmpty(scope.refreshOn)) { - scope.$on(scope.refreshOn, function(event, queryFn) { - scope.options.query = queryFn(scope.options); - scope.load(); - }); - } - - } - }; - }); - -})(); diff --git a/ui/app/scripts/directives/charts/histo-chart.js b/ui/app/scripts/directives/charts/histo-chart.js deleted file mode 100644 index defdf79e7d..0000000000 --- a/ui/app/scripts/directives/charts/histo-chart.js +++ /dev/null @@ -1,111 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveDirectives').directive('histoChart', function($http, ChartSrv, NotificationSrv) { - return { - restrict: 'E', - scope: { - 'autoload': '=', - 'options': '=', - 'refreshOn': '@' - }, - templateUrl: 'views/directives/charts/histo-chart.html', - link: function(scope) { - scope.chart = {}; - scope.intervals = ChartSrv.timeIntervals; - scope.interval = scope.intervals[2]; - - scope.buildQuery = function() { - var criteria = _.without([ - scope.options.filter, - scope.options.query - ], null, undefined, '', '*'); - - return criteria.length === 1 ? criteria[0] : {_and: criteria}; - }; - - scope.getCountFn = function(val) { - return val.count || 0; - }; - - scope.load = function() { - var options = { - params: { - entity: scope.options.type, - fields: scope.options.fields, - q: scope.buildQuery(), - duration: scope.interval.code - } - }; - - $http.post('./api/' + options.params.entity + '/_stats', { - "query": options.params.q, - "stats": [{ - "_agg": "time", - "_fields": options.params.fields, - "_interval": options.params.duration, - "_select": [{ - "_agg": "count" - }] - }] - }).then(function(response) { - var labels = _.keys(response.data).map(function(d) { - return moment(d, 'YYYYMMDDTHHmmZZ').format('YYYY-MM-DD'); - }); - - var values = _.values(response.data); - var columns = _.map(scope.options.fields, function(field) { - var fieldValues = _.pluck(values, field); - - return [field].concat(_.map(fieldValues, scope.getCountFn)); - }); - - scope.chart = { - data: { - x: 'date', - columns: [ - ['date'].concat(labels) - ].concat(columns), - names: scope.options.names || {}, - type: 'bar', - types: scope.options.types || {} - }, - bar: { - width: { - ratio: 0.1 - } - }, - axis: { - x: { - type: 'timeseries', - tick: { - format: '%Y-%m-%d', - rotate: 90, - height: 50 - } - } - }, - zoom: { - enabled: scope.options.zoom || false - } - }; - }, function(err) { - NotificationSrv.error('histoChart', err.data, err.status); - }); - }; - - if (scope.autoload === true) { - scope.load(); - } - - if (!_.isEmpty(scope.refreshOn)) { - scope.$on(scope.refreshOn, function(event, queryFn) { - scope.options.query = queryFn(scope.options); - scope.load(); - }); - } - - } - }; - }); - -})(); diff --git a/ui/app/scripts/directives/charts/metric-histo-chart.js b/ui/app/scripts/directives/charts/metric-histo-chart.js deleted file mode 100644 index 9179970c50..0000000000 --- a/ui/app/scripts/directives/charts/metric-histo-chart.js +++ /dev/null @@ -1,190 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveDirectives').directive('metricHistoChart', function($http, $interpolate, MetricsCacheSrv, ChartSrv, NotificationSrv) { - return { - restrict: 'E', - scope: { - 'autoload': '=', - 'options': '=', - 'refreshOn': '@' - }, - templateUrl: 'views/directives/charts/metric-histo-chart.html', - link: function(scope) { - scope.chart = {}; - scope.allAggregations = ChartSrv.aggregations; - scope.intervals = ChartSrv.timeIntervals; - scope.interval = scope.intervals[2]; - scope.selectedMetrics = []; - scope.selectedAggregations = []; - - scope.buildQuery = function() { - var criteria = _.without([ - scope.options.filter, - scope.options.query - ], null, undefined, '', '*'); - - return criteria.length === 1 ? criteria[0] : {_and: criteria}; - }; - - scope.getSelectors = function() { - var selectors = []; - - _.each(scope.options.metrics, function(m) { - _.each(scope.options.aggregations, function(a) { - selectors.push({ - _agg: a, - _field: m - }); - }); - }); - - return selectors; - }; - - scope.load = function() { - var options = { - params: { - entity: scope.options.entity, - field: scope.options.field, - duration: scope.interval.code, - q: scope.buildQuery(), - metrics: scope.options.metrics.sort() - } - }; - - scope.columnKeys = []; - - $http.post('./api/' + options.params.entity + '/_stats', { - "query": options.params.q, - "stats": [{ - "_agg": "time", - "_fields": [options.params.field], - "_interval": options.params.duration, - "_select": scope.getSelectors() - }] - }).then(function(response) { - var labels = _.keys(response.data).map(function(d) { - return moment(d, 'YYYYMMDDTHHmmZZ').format('YYYY-MM-DD'); - }); - - var columns = []; - var values = _.pluck(_.values(response.data), options.params.field); - - _.each(scope.options.metrics, function(metric) { - _.each(scope.options.aggregations, function(agg) { - scope.columnKeys.push(metric + '.' + agg); - columns.push([metric + '.' + agg].concat(_.pluck(values, agg + '_' + metric))); - }); - }); - - scope.names = {}; - scope.axes = {}; - scope.types = {}; - _.each(scope.columnKeys, function(ck) { - var segs = ck.replace('metrics.', '').split('.'); - scope.names[ck] = segs[1] + ' of ' + segs[0]; - scope.axes[ck] = (segs[1] === 'count') ? 'y2' : 'y'; - scope.types[ck] = (segs[1] === 'count') ? 'bar' : (scope.type || 'line'); - }); - - var chart = { - data: { - x: 'date', - columns: [ - ['date'].concat(labels) - ].concat(columns), - names: scope.names || {}, - type: scope.type || 'bar', - types: scope.types || {}, - axes: scope.axes || {} - }, - bar: { - width: { - ratio: 0.1 - } - }, - axis: { - x: { - type: 'timeseries', - tick: { - format: '%Y-%m-%d', - rotate: 90, - height: 50 - } - }, - y2: { - show: scope.options.aggregations.indexOf('count') !== -1 - } - }, - zoom: { - enabled: scope.options.zoom || false - } - }; - - - scope.chart = chart; - }, function(err) { - NotificationSrv.error('metricHistoChart', err.data, err.status); - }); - }; - - // Load all metrics - MetricsCacheSrv.all().then(function(metrics) { - var keys = []; - - // Get all metrics - scope.allMetrics = _.keys(metrics).map(function(key) { - keys.push('metrics.' + key); - var metric = metrics[key]; - - return { - id: 'metrics.' + metric.name, - label: metric.title - }; - }); - - // If no metrics have been specified in the options, use all metrics - if (!scope.options.metrics || scope.options.metrics.length === 0) { - scope.options.metrics = keys; - } - - // Prepare the data for the metrics filter dropdown - scope.selectedMetrics = scope.options.metrics ? _.map(scope.options.metrics, function(m) { - return { - id: m - }; - }) : []; - - // Prepare the data for the aggregations filter dropdown - scope.selectedAggregations = scope.options.aggregations ? _.map(scope.options.aggregations, function(agg) { - return { - id: agg - }; - }) : []; - - scope.$watchCollection('selectedMetrics', function() { - scope.options.metrics = _.pluck(scope.selectedMetrics, 'id'); - }); - scope.$watchCollection('selectedAggregations', function() { - scope.options.aggregations = _.pluck(scope.selectedAggregations, 'id'); - }); - - // Run the first chart load - if (scope.autoload === true) { - scope.load(); - } - }); - - if (!_.isEmpty(scope.refreshOn)) { - scope.$on(scope.refreshOn, function(event, queryFn) { - scope.options.query = queryFn(scope.options); - scope.load(); - }); - } - - } - - }; - }); - -})(); diff --git a/ui/app/scripts/directives/dashboard/bar.js b/ui/app/scripts/directives/dashboard/bar.js new file mode 100644 index 0000000000..3a14270194 --- /dev/null +++ b/ui/app/scripts/directives/dashboard/bar.js @@ -0,0 +1,178 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives').directive('dashboardBar', function($http, $state, DashboardSrv, NotificationSrv) { + return { + restrict: 'E', + scope: { + filter: '=?', + options: '=', + entity: '=', + autoload: '=', + mode: '=', + refreshOn: '@', + resizeOn: '@', + metadata: '=' + }, + template: '', + link: function(scope) { + scope.error = false; + scope.chart = {}; + + scope.intervals = DashboardSrv.timeIntervals; + scope.interval = scope.intervals[2]; + + scope.load = function() { + if(!scope.entity) { + scope.error = true; + return; + } + + scope.prepareSeriesNames = function() { + if(!scope.options.field) { + return {}; + } + + var field = scope.entity.attributes[scope.options.field]; + + if(field.values.length === 0) { + // This is not an enumerated field + // Labels and colors customization is not available + return {}; + } + + var names = scope.options.names || {}; + + _.each(field.values, function(val, index) { + if(!names[val]) { + names[val] = field.labels[index] || val; + } + }); + + return names; + }; + + var query = DashboardSrv.buildChartQuery(scope.filter, scope.options.query); + + var statsPromise = $http.post('./api' + scope.entity.path + '/_stats', { + query: query, + stats: [{ + _agg: 'time', + _fields: [scope.options.dateField], + _interval: scope.options.interval || scope.interval.code, + _select: [{ + _agg: 'field', + _field: scope.options.field, + _select: [{ + _agg: 'count' + }] + }] + }] + }); + + statsPromise.then(function(response) { + scope.error = false; + var len = _.keys(response.data).length, + data = {_date: (new Array(len)).fill(0)}; + + var rawData = {}; + _.each(response.data, function(value, key) { + rawData[key] = value[scope.options.dateField] + }); + + _.each(rawData, function(value) { + _.each(_.keys(value), function(key){ + data[key] = (new Array(len)).fill(0); + }); + }); + + var i = 0; + var orderedDates = _.sortBy(_.keys(rawData)); + + _.each(orderedDates, function(key) { + var value = rawData[key]; + data._date[i] = moment(key * 1).format('YYYY-MM-DD'); + + _.each(_.keys(value), function(item) { + data[item][i] = value[item].count; + }); + + i++; + }); + + + scope.options.names = scope.prepareSeriesNames(); + scope.colors = {}; + + scope.data = data; + + var chart = { + data: { + x: '_date', + json: scope.data, + type: 'bar', + names: scope.options.names || {}, + colors: scope.options.colors || {}, + groups: scope.options.stacked === true ? [_.without(_.keys(data), '_date')] : [] + }, + bar: { + width: { + ratio: 1 - Math.exp(-len/20) + } + }, + axis: { + x: { + type: 'timeseries', + tick: { + format: '%Y-%m-%d', + rotate: 90, + height: 50 + } + } + }, + zoom: { + enabled: scope.options.zoom || false + } + }; + + scope.chart = chart; + }, function(err) { + scope.error = true; + NotificationSrv.log('Failed to fetch data, please edit the widget definition', 'error'); + }); + }; + + scope.getCsv = function() { + var dates = scope.data._date; + var keys = _.keys(scope.data); + var headers = _.extend({_date: 'Date'}, scope.names); + + var csv = [{data: _.map(keys, function(key){ + return headers[key] || key; + }).join(';')}]; + + var row = []; + for(var i=0; i', + link: function(scope) { + scope.error = false; + scope.chart = {}; + + scope.prepareSeriesNames = function() { + if(!scope.options.field) { + return {}; + } + + var field = scope.entity.attributes[scope.options.field]; + + if(field.values.length === 0) { + // This is not an enumerated field + // Labels and colors customization is not available + return {}; + } + + var names = scope.options.names || {}; + + _.each(field.values, function(val, index) { + if(!names[val]) { + names[val] = field.labels[index] || val; + } + }); + + return names; + }; + + scope.load = function() { + if(!scope.entity) { + scope.error = true; + return; + } + + var query = DashboardSrv.buildChartQuery(scope.filter, scope.options.query); + + var statConfig = { + query: query, + + objectType: scope.entity.path, + field: scope.options.field, + sort: scope.options.sort ? [scope.options.sort] : '-_count', + limit: scope.options.limit || 10 + }; + + scope.options.names = scope.prepareSeriesNames(); + + StatSrv.getPromise(statConfig).then( + function(response) { + scope.error = false; + var data = {}; + var total = response.data.count; + + delete response.data.count; + + _.each(response.data, function(val, key) { + data[key] = val.count; + }); + + scope.data = data; + + scope.chart = { + data: { + json: scope.data, + type: 'donut', + names: scope.options.names || {}, + colors: scope.options.colors || {}, + // onclick: function(d) { + // var criteria = [{ _type: scope.options.entity }, { _field: scope.options.field, _value: d.id }]; + // + // if (scope.options.query && scope.options.query !== '*') { + // criteria.push(scope.options.query); + // } + // + // var searchQuery = { + // _and: criteria + // }; + // + // $state.go('app.search', { + // q: Base64.encode(angular.toJson(searchQuery)) + // }); + // } + }, + donut: { + title: 'Total: ' + total, + label: { + format: function(value) { + return value; + } + } + } + }; + }, + function(err) { + scope.error = true; + NotificationSrv.log('Failed to fetch data, please edit the widget definition', 'error'); + } + ); + }; + + scope.getCsv = function() { + var csv = []; + _.each(scope.data, function(val, key) { + csv.push({data: key + ';' + val}); + }); + return csv; + }; + + if (scope.autoload === true) { + scope.load(); + } + + if (!_.isEmpty(scope.refreshOn)) { + scope.$on(scope.refreshOn, function(event, filter) { + scope.filter = filter; + scope.load(); + }); + } + } + }; + }); +})(); diff --git a/ui/app/scripts/directives/dashboard/item.js b/ui/app/scripts/directives/dashboard/item.js new file mode 100644 index 0000000000..d4b67cb21b --- /dev/null +++ b/ui/app/scripts/directives/dashboard/item.js @@ -0,0 +1,210 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives').directive('dashboardItem', function(DashboardSrv, UserSrv, $uibModal, $timeout, $q) { + return { + restrict: 'E', + replace: true, + scope: { + rowIndex: '=', + colIndex: '=', + component: '=', + metadata: '=', + filter: '=?', + autoload: '=', + refreshOn: '@', + resizeOn: '@', + mode: '@', + showEdit: '=', + showRemove: '=', + onRemove: '&' + }, + templateUrl: 'views/directives/dashboard/item.html', + link: function(scope, element) { + scope.typeClasses = DashboardSrv.typeClasses; + scope.timeIntervals = DashboardSrv.timeIntervals; + scope.aggregations = DashboardSrv.aggregations; + scope.serieTypes = DashboardSrv.serieTypes; + scope.sortOptions = DashboardSrv.sortOptions; + + scope.layout = { + activeTab: 0 + }; + scope.query = null; + scope.skipFields = function(fields, types) { + return _.filter(fields, function(item) { + return types.indexOf(item.type) === -1; + }); + }; + + scope.pickFields = function(fields, types) { + return _.filter(fields, function(item) { + return types.indexOf(item.type) !== -1; + }); + } + + scope.fieldsForAggregation = function(fields, agg) { + if(agg === 'count') { + return []; + } else if(agg === 'sum' || agg === 'avg') { + return scope.pickFields(fields, ['number']); + } else { + return fields; + } + } + + if(scope.component.id) { + scope.$on('edit-chart-' + scope.component.id, function(data) { + scope.editItem(); + }); + } + + scope.editItem = function() { + var modalInstance = $uibModal.open({ + scope: scope, + controller: ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) { + $scope.cancel = function() { + $uibModalInstance.dismiss(); + }; + + $scope.save = function() { + $uibModalInstance.close($scope.component.options); + }; + }], + templateUrl: 'views/directives/dashboard/edit.dialog.html', + size: 'lg' + }); + + modalInstance.result.then(function(definition) { + var entity = scope.component.options.entity; + + if(!entity) { + return; + } + + // Set the computed query + definition.query = DashboardSrv.buildFiltersQuery(scope.metadata[entity].attributes, scope.component.options.filters); + + // Set the computed querie of series if available + _.each(definition.series, function(serie) { + if(serie.filters) { + serie.query = DashboardSrv.buildFiltersQuery(scope.metadata[entity].attributes, serie.filters); + } + }) + + scope.component.options = definition; + + $timeout(function() { + scope.$broadcast(scope.refreshOn, scope.filter); + }, 500); + }); + }; + + scope.editorFor = function(filter) { + if (filter.type === null) { + return; + } + var field = scope.metadata[scope.component.options.entity].attributes[filter.field]; + var type = field.type; + + if ((type === 'string' || type === 'number') && field.values.length > 0) { + return 'enumeration'; + } + + return filter.type; + }; + + scope.promiseFor = function(filter, query) { + var field = scope.metadata[scope.component.options.entity].attributes[filter.field]; + + var promise = null; + + if(field.type === 'user') { + promise = UserSrv.autoComplete(query); + } else if (field.values.length > 0) { + promise = $q.resolve( + _.map(field.values, function(item, index) { + return { + text: item, + label: field.labels[index] || item + }; + }) + ); + } else { + promise = $q.resolve([]); + } + + return promise.then(function(response) { + var list = []; + + list = _.filter(response, function(item) { + var regex = new RegExp(query, 'gi'); + return regex.test(item.label); + }); + + return $q.resolve(list); + }); + }; + + scope.addFilter = function() { + scope.component.options.filters = scope.component.options.filters || []; + + scope.component.options.filters.push({ + field: null, + type: null + }); + }; + + scope.removeFilter = function(index) { + scope.component.options.filters.splice(index, 1); + }; + + scope.setFilterField = function(filter) { + var entity = scope.component.options.entity; + var field = scope.metadata[entity].attributes[filter.field]; + + filter.type = field.type; + + if (field.type === 'date') { + filter.value = { + from: null, + to: null + }; + } else { + filter.value = null; + } + }; + + scope.addSerie = function() { + scope.component.options.series = scope.component.options.series || []; + + scope.component.options.series.push({ + agg: null, + field: null + }); + }; + + scope.addSerieFilter = function(serie) { + serie.filters = serie.filters || []; + + serie.filters.push({ + field: null, + type: null + }); + }; + + scope.removeSerieFilter = function(serie, index) { + serie.filters.splice(index, 1); + }; + + + scope.removeSerie = function(index) { + scope.component.options.series.splice(index, 1); + }; + + scope.showQuery = function() { + scope.query = DashboardSrv.buildFiltersQuery(scope.metadata[scope.component.options.entity], scope.component.options.filters); + }; + } + }; + }); +})(); diff --git a/ui/app/scripts/directives/dashboard/line.js b/ui/app/scripts/directives/dashboard/line.js new file mode 100644 index 0000000000..3e9e3aea2b --- /dev/null +++ b/ui/app/scripts/directives/dashboard/line.js @@ -0,0 +1,194 @@ +(function() { + 'use strict'; + angular.module('theHiveDirectives').directive('dashboardLine', function($http, $state, DashboardSrv, NotificationSrv) { + return { + restrict: 'E', + scope: { + filter: '=?', + options: '=', + entity: '=', + autoload: '=', + mode: '=', + refreshOn: '@', + resizeOn: '@', + metadata: '=' + }, + template: '', + link: function(scope) { + scope.error = false; + scope.chart = {}; + + scope.intervals = DashboardSrv.timeIntervals; + scope.interval = scope.intervals[2]; + + scope.load = function() { + if(!scope.entity) { + scope.error = true; + return; + } + + var query = DashboardSrv.buildChartQuery(scope.filter, scope.options.query); + + var statsPromise = $http.post('./api' + scope.entity.path + '/_stats', { + query: query, + stats: [{ + _agg: 'time', + _fields: [scope.options.field], + _interval: scope.options.interval || scope.interval.code, + _select: _.map(scope.options.series || [], function(serie, index) { + var s = { + _agg: serie.agg, + _name: 'agg_' + (index + 1), + _query: serie.query || {} + }; + + if(serie.agg !== 'count') { + s._field = serie.field; + } + + return s; + }) + }] + }); + + statsPromise.then(function(response) { + scope.error = false; + var labels = _.keys(response.data).map(function(d) { + return moment(d * 1).format('YYYY-MM-DD'); + }); + var len = labels.length, + data = {_date: (new Array(len)).fill(0)}, + rawData = {}; + + _.each(response.data, function(value, key) { + rawData[key] = value[scope.options.field] + }); + + _.each(rawData, function(value) { + _.each(_.keys(value), function(key){ + data[key] = (new Array(len)).fill(0); + }); + }); + + var i = 0; + var orderedDates = _.sortBy(_.keys(rawData)); + + _.each(orderedDates, function(key) { + var value = rawData[key]; + data._date[i] = moment(key * 1).format('YYYY-MM-DD'); + + _.each(_.keys(value), function(item) { + data[item][i] = value[item]; + }); + + i++; + }); + + scope.types = {}; + scope.names = {}; + scope.axes = {}; + scope.colors = {}; + + var serieTypes = _.uniq(_.pluck(scope.options.series, 'type')).length; + + _.each(scope.options.series, function(serie, index) { + var key = serie.field, + agg = serie.agg, + dataKey = agg === 'count' ? 'count' : (agg + '_' + key), + columnKey = 'agg_' + (index + 1); + + scope.types[columnKey] = serie.type || 'line'; + scope.names[columnKey] = serie.label || (agg === 'count' ? 'count' : (agg + ' of ' + key)); + scope.axes[columnKey] = serieTypes === 1 ? 'y' : ((scope.types[columnKey] === 'bar') ? 'y2' : 'y'); + scope.colors[columnKey] = serie.color; + }); + + // Compute stack groups + var groups = {}; + _.each(scope.types, function(value, key) { + if (groups[value]) { + groups[value].push(key); + } else { + groups[value] = [key]; + } + }); + scope.groups = scope.options.stacked === true ? _.values(groups) : {}; + + scope.data = data; + + var chart = { + data: { + x: '_date', + json: scope.data, + names: scope.names || {}, + type: scope.type || 'bar', + types: scope.types || {}, + axes: scope.axes || {}, + colors: scope.colors || {}, + groups: scope.groups || [] + }, + bar: { + width: { + ratio: 1 - Math.exp(-len/20) + } + }, + axis: { + x: { + type: 'timeseries', + tick: { + format: '%Y-%m-%d', + rotate: 90, + height: 50 + } + }, + y2: { + show: _.values(scope.axes).indexOf('y2') !== -1 + } + }, + zoom: { + enabled: scope.options.zoom || false + } + }; + + scope.chart = chart; + }, function(err) { + scope.error = true; + NotificationSrv.log('Failed to fetch data, please edit the widget definition', 'error'); + }); + }; + + scope.getCsv = function() { + var dates = scope.data._date; + var keys = _.keys(scope.data); + var headers = _.extend({_date: 'Date'}, scope.names); + + var csv = [{data: _.map(keys, function(key){ + return headers[key] || key; + }).join(';')}]; + + var row = []; + for(var i=0; i': + return {'_gt': criterion}; + case '>=': + return {'_gte': criterion}; + case '!=': + return {'_not': criterion}; + default: + return {'_field': filter.field, '_value': filter.value.value}; + } + } + + this._buildQueryFromListFilter = function(fieldDef, filter) { + if (!filter || !filter.value) { + return null; + } + var operator = filter.value.operator || 'any'; + var values = _.pluck(filter.value.list, 'text'); + + if(values.length > 0) { + var criterions = _.map(values, function(val) { + //return {_string: filter.field + ':' + val}; + return {_field: filter.field, _value: val}; + }); + + var criteria = {}; + switch(operator) { + case 'all': + criteria = criterions.length === 1 ? criterions[0] : { _and: criterions }; + break; + case 'none': + criteria = { + _not: criterions.length === 1 ? criterions[0] : { _or: criterions } + }; + break; + case 'any': + default: + criteria = criterions.length === 1 ? criterions[0] : { _or: criterions }; + } + + return criteria; + } + + return null; + }; + + this._buildQueryFromDateFilter = function(fieldDef, filter) { + var value = filter.value; + + var start = value.from && value.from != null ? value.from.getTime() : null; + var end = value.to && value.to != null ? value.to.setHours(23, 59, 59, 999) : null; + + if (start !== null && end !== null) { + return { + _between: { _field: filter.field, _from: start, _to: end } + }; + } else if (start !== null) { + return { + _gt: { _field: filter.field, _value: start } + }; + } else { + return { + _lt: { _field: filter.field, _value: end } + }; + } + + return null; + }; + + this._buildQueryFromFilter = function(fieldDef, filter) { + if (filter.type === 'date') { + return this._buildQueryFromDateFilter(fieldDef, filter); + } else if(filter.value.list || filter.type === 'user' || filter.field === 'tags' || filter.type === 'enumeration' || fieldDef.values.length > 0) { + return this._buildQueryFromListFilter(fieldDef, filter); + } else if(filter.type === 'number') { + return this._buildQueryFromNumberFilter(fieldDef, filter); + } else if(filter.type === 'boolean') { + return this._buildQueryFromDefaultFilter(fieldDef, filter); + } + return { + _string: filter.field + ':"' + filter.value +'"' + }; + }; + + this.buildFiltersQuery = function(fields, filters) { + var criterias = + _.map(filters, function(filter) { + return self._buildQueryFromFilter(fields[filter.field], filter); + }) || []; + + criterias = _.without(criterias, null, undefined); + + return criterias.length === 0 ? {} : criterias.length === 1 ? criterias[0] : { _and: criterias }; + }; + }); +})(); diff --git a/ui/app/scripts/services/StatSrv.js b/ui/app/scripts/services/StatSrv.js index 52ab6638ab..b7e60e1cd5 100644 --- a/ui/app/scripts/services/StatSrv.js +++ b/ui/app/scripts/services/StatSrv.js @@ -29,7 +29,12 @@ _agg: 'count' }); - return $http.post('./api/' + config.objectType.replace(/_/g, '/') + '/_stats', { + var entity = config.objectType.replace(/_/g, '/'); + if(entity[0] === '/') { + entity = entity.substr(1); + } + + return $http.post('./api/' + entity + '/_stats', { query: config.query, stats: stats }) diff --git a/ui/app/scripts/services/TemplateSrv.js b/ui/app/scripts/services/TemplateSrv.js deleted file mode 100644 index b47888a608..0000000000 --- a/ui/app/scripts/services/TemplateSrv.js +++ /dev/null @@ -1,19 +0,0 @@ -(function() { - 'use strict'; - angular.module('theHiveServices') - .factory('TemplateSrv', function($resource) { - return $resource('./api/case/template/:templateId', {}, { - update: { - method: 'PATCH', - }, - query: { - method: 'POST', - url: './api/case/template/_search', - isArray: true, - params: { - range: 'all' - } - } - }); - }); -})(); diff --git a/ui/app/scripts/services/UserSrv.js b/ui/app/scripts/services/UserSrv.js index e5ce4b6fb0..e61f7adac0 100644 --- a/ui/app/scripts/services/UserSrv.js +++ b/ui/app/scripts/services/UserSrv.js @@ -109,5 +109,29 @@ angular.module('theHiveServices') return defer.promise; }; + res.autoComplete = function(query) { + return res.list({ + _and: [ + { + status: 'Ok' + } + ] + }).then(function(data) { + return _.map(data, function(user) { + return { + label: user.name, + text: user.id + }; + }); + }).then(function(users) { + var filtered = _.filter(users, function(user) { + var regex = new RegExp(query, 'gi'); + return regex.test(user.label); + }); + + return filtered; + }); + } + return res; }); diff --git a/ui/app/scripts/services/UtilsSrv.js b/ui/app/scripts/services/UtilsSrv.js index 06ed307cc3..381a3ef122 100644 --- a/ui/app/scripts/services/UtilsSrv.js +++ b/ui/app/scripts/services/UtilsSrv.js @@ -5,6 +5,15 @@ var sensitiveTypes = ['url', 'ip', 'mail', 'domain', 'filename']; var service = { + guid: function () { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + + s4() + '-' + s4() + s4() + s4(); + }, objectify: function(arr, property) { return _.map(arr, function(str){ var obj = {}; diff --git a/ui/app/scripts/services/VersionSrv.js b/ui/app/scripts/services/VersionSrv.js index c5c0390a96..112bb029d4 100644 --- a/ui/app/scripts/services/VersionSrv.js +++ b/ui/app/scripts/services/VersionSrv.js @@ -25,8 +25,8 @@ hasCortex: function() { try { var service = cache.connectors.cortex; - - return service.enabled && service.servers.length; + + return service.enabled && _.pluck(service.servers, 'status').indexOf('OK') !== -1; } catch (err) { return false; } diff --git a/ui/app/scripts/utils/saveSvgAsPng.js b/ui/app/scripts/utils/saveSvgAsPng.js index 1d79989c41..25382b477d 100644 --- a/ui/app/scripts/utils/saveSvgAsPng.js +++ b/ui/app/scripts/utils/saveSvgAsPng.js @@ -147,7 +147,7 @@ var svg = doctype + outer.innerHTML; // encode then decode to handle `btoa` on Unicode; see MDN for `btoa`. - var uri = 'data:image/svg+xml;base64,' + window.btoa(decodeURIComponent(encodeURIComponent(svg))); + var uri = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(svg))); if (cb) { cb(uri); } diff --git a/ui/app/styles/case-template.css b/ui/app/styles/case-template.css new file mode 100644 index 0000000000..e9bd040f6b --- /dev/null +++ b/ui/app/styles/case-template.css @@ -0,0 +1,11 @@ +.metric-item, +.customfield-item { + background-color: #f9f9f9; + padding: 10px; + margin-bottom: 5px; +} + +.task-item { + background-color: #f9f9f9; + margin-bottom: 5px; +} diff --git a/ui/app/styles/dashboard.css b/ui/app/styles/dashboard.css new file mode 100644 index 0000000000..426a299d64 --- /dev/null +++ b/ui/app/styles/dashboard.css @@ -0,0 +1,208 @@ +body, +.c3 svg { + font-family: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.dashboards-list .dashboard-item { + padding: 5px; + /*border-bottom: 1px dashed #ccc;*/ +} +.dashboards-list .dashboard-item:nth-child(even) { + background: #f9f9f9; +} +.dashboards-list .dashboard-item:nth-child(odd) { + background: #FFF; +} + +.dashboards-list .dashboard-item .media-right { + vertical-align: middle; +} + +.dashboard-content * { + min-width: 0; +} + +.dashboard-content { + margin-top: 20px; + padding-left: 0; +} +.dashboard-content > li { + list-style: none; +} +.dashboard-content > .dndDraggingSource { + display: none; +} +.dashboard-content > .dndPlaceholder { + background-color: #f9f9f9; + border: 1px dashed #aacbed; + display: block; + margin-top: 10px; + min-height: 110px; + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; +} +.dashboard-content > .dndPlaceholder:after { + color: #999999; + content: "Drop row here"; +} + +.dashboard-content .c3-chart { + width: 100% !important; + overflow: hidden; +} +.dashboard-content .c3-chart svg { + width: 100% !important; +} + +.dashboard-row { + display: flex; + flex-direction: column; +} +.chart-container { + min-height: 100px; + background-color: #f9f9f9; + border: 1px dashed #aacbed; + margin-top: 10px; + padding: 15px; +} + +.chart-container-dropzone { + min-height: 50px; + display: flex; + justify-content: space-around; + align-items: stretch; +} + +.chart-container-dropzone > .dndDraggingSource { + display: none; +} + +.chart-container-dropzone > .dndPlaceholder { + background-color: #eeeeee; + display: flex; + justify-content: center; + align-items: center; + line-height: 110px; + font-size: 20px; +} +.chart-container-dropzone > .dndPlaceholder:after { + color: #999999; + content: "Drop item here"; +} + +.chart-container-dropzone > * { + -webkit-box-flex: 1; /* OLD - iOS 6-, Safari 3.1-6 */ + -moz-box-flex: 1; /* OLD - Firefox 19- */ + width: 20%; /* For old syntax, otherwise collapses. */ + -webkit-flex: 1; /* Chrome */ + -ms-flex: 1; /* IE 10 */ + flex: 1; + margin: 0 5px; +} + +.dashboard-view .chart-container-dropzone > *:first-child { + margin-left: 0; +} + +.dashboard-view .chart-container-dropzone > *:last-child{ + margin-right: 0px; +} + +dashboard-item .box { + margin-bottom: 0; +} + +.dashboard-row { + margin-bottom: 10px; +} + +.dashboard-row .c3-container { + display: -webkit-flex; + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: flex; + flex-direction: column; +} + +.dashboard-row .c3-container .c3-chart { + flex: 1; +} + + .dashboard-serie { + background-color: #f9f9f9; + margin-bottom: 15px; + padding: 10px; +} + +.dashboard-serie > .form-inline { + display: flex; + justify-content: start; + align-items: stretch; +} + +.dashboard-serie > .form-inline > div { + margin: 0 4px; +} + +.dashboard-serie > .form-inline input { + width: 100% +} + +.dashboard-serie > .form-inline select { + max-width: 200px; +} + +.dashboard-period > div{ + height: 34px; + display:flex; + justify-content:start; + align-items:stretch; +} + +.dashboard-period .label{ + display:flex; + align-items:center; + justify-content: center; +} + +.dashboard-edit.left-toolbox { + padding-left: 120px; +} +.dashboard-edit.right-toolbox { + padding-right: 120px; +} + +.dashboard-toolbox { + background-color: #fff; + position: absolute; + width: 110px; + padding: 10px; + text-align: center; +} + +.dashboard-toolbox.right-toolbox { + right: 15px; +} +.dashboard-toolbox.left-toolbox { + left: 15px; +} + +.dashboard-toolbox > div { + margin: auto; + margin-bottom: 4px; +} +.dashboard-toolbox > div:last-child { + margin-bottom: 0; +} +.c3-container .c3-error { + height:300px; + display:flex; + justify-content:center; + align-items:center; +} +.c3-container .c3-error > div { + flex: 1; +} diff --git a/ui/app/styles/main.css b/ui/app/styles/main.css index 564a5fb349..c145a43104 100644 --- a/ui/app/styles/main.css +++ b/ui/app/styles/main.css @@ -503,6 +503,20 @@ footer.main-footer { footer .footer-logo { height: 40px; + border-radius: 50%; +} + +footer .footer-logo.logo-ok{ + background-color: #00a65a; +} +footer .footer-logo.logo-error{ + background-color: #dd4b39; +} +footer .footer-logo.logo-error{ + background-color: #dd4b39; +} +footer .footer-logo.logo-warning{ + background-color: #f39c12; } report:empty { @@ -515,5 +529,9 @@ span.action-button { table tr td.task-actions span.action-button { width: 60px; - display: inline-block; + display: inline-block; +} + +.misp-export .status-label { + display: block; } diff --git a/ui/app/views/components/app-container.component.html b/ui/app/views/components/app-container.component.html index 71c4eb450e..ca3e599c36 100644 --- a/ui/app/views/components/app-container.component.html +++ b/ui/app/views/components/app-container.component.html @@ -10,11 +10,19 @@ Version: {{appConfig.versions.TheHive}} + - + + - +
diff --git a/ui/app/views/components/header.component.html b/ui/app/views/components/header.component.html index a80d310b20..9f94a063bf 100644 --- a/ui/app/views/components/header.component.html +++ b/ui/app/views/components/header.component.html @@ -26,9 +26,7 @@
  • - From - {{template.name | uppercase}} - template + {{template.name | uppercase}}
  • @@ -49,10 +47,15 @@ -
  • + +
  • + + Dashboards +