From 2e0ab0ac943cb37482629850269ce44d5066ed9c Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Tue, 14 Mar 2023 04:03:40 -0400 Subject: [PATCH 1/2] Switch visualization library to vis.js, add new features --- README.Javascript.rst | 350 + application.css | 192 +- application.js | 790 +- index.html | 68 +- stix2viz/__init__.py | 40 +- stix2viz/d3/LICENSE | 26 - stix2viz/d3/README.md | 9 - stix2viz/d3/d3.js | 9553 ------ stix2viz/d3/d3.min.js | 5 - stix2viz/stix2viz/stix2viz.js | 2657 +- stix2viz/visjs/vis-network.js | 44734 ++++++++++++++++++++++++++++ stix2viz/visjs/vis-network.min.js | 49 + 12 files changed, 47613 insertions(+), 10860 deletions(-) create mode 100644 README.Javascript.rst delete mode 100644 stix2viz/d3/LICENSE delete mode 100644 stix2viz/d3/README.md delete mode 100644 stix2viz/d3/d3.js delete mode 100644 stix2viz/d3/d3.min.js create mode 100644 stix2viz/visjs/vis-network.js create mode 100644 stix2viz/visjs/vis-network.min.js diff --git a/README.Javascript.rst b/README.Javascript.rst new file mode 100644 index 0000000..19bfe32 --- /dev/null +++ b/README.Javascript.rst @@ -0,0 +1,350 @@ +.. contents:: + +================ +Javascript Usage +================ + +The STIX Visualizer is written in Javascript (the small bit of Python is for +integration into Jupyter). This file documents the Javascript module. + +Library Design +============== + +STIX is a graph-like data model. This library is designed to visualize STIX +content in the natural way, as a graph. It converts STIX content into a set of +nodes and a set of edges. + +The library separates the creation of graph data (as converted from STIX +content) from how that data is visualized. This allows for different +views of the same graph data. If there is a lot of STIX content for example, a +visual graph rendering may be too cluttered or may not perform well. The +primary rendering of the graph data is intended to be a graph. But alternative +views may be more suitable under some circumstances. + +Two view implementations are included. There is a graph view based on +`visjs `_, and a simpler textual list view which just puts +graph data into a simple HTML list. + +Javascript API +============== + +The ``stix2viz`` module exports the following functions: + +- ``makeGraphData(stixContent, config)`` +- ``makeGraphView(domElement, nodeDataSet, edgeDataSet, stixIdToObject, config)`` +- ``makeListView(domElement, nodeDataSet, edgeDataSet, stixIdToObject, config)`` + +The configuration object which is the last parameter of all of these functions +is documented in the `configuration `_ section. + +Making Graph Data +----------------- + +The first step to visualizing STIX content using this library, is to create the +base graph data. This is done with ``makeGraphData``. It accepts STIX content +in a few different forms, including a single STIX object, array of objects, +or bundle of objects, as JSON text, plain Javascript object, array, or Map. + +The function returns a 3-tuple, +:code:`[nodeDataSet, edgeDataSet, stixIdToObject]`. The first two elements are +`DataSet `_ objects with +the graph data; the last contains the same STIX content as was passed in, but +in a normalized form, to simplify handling. The normalized form is a Map +instance which maps STIX IDs to Map instances containing the data for each +STIX object. All nested mappings are recursively converted to Map instances as +necessary. + +Making a View +------------- + +Creation of a view is done within the context of a web page. One element from +the web page must be chosen to act as the root of the content which will +comprise the view. The ``makeGraphView`` or ``makeListView`` function is +called with this root element, all the values obtained from making the graph +data, and a configuration object. The view should automatically appear in the +web page (depending on its styling). + +These functions return instances of internal classes: the classes themselves +are not exported, but the functions act as factories for them. There are some +utility methods defined on the classes, e.g. a ``destroy()`` method +for destroying the view and releasing resources. + +Configuration +============= + +All three of the public API functions accept a configuration object. Different +library components require different types of configuration, but it can all +co-exist within the same object. The components will look for and use whatever +settings they need. + +Configuration can be given as JSON text, a plain Javascript object, or a Map. +A few top-level keys map to various types of config settings, described in +the following subsections. + +Per STIX Type Settings +---------------------- + +Some settings are naturally organized per STIX type, e.g. how one labels graph +nodes corresponding to particular STIX types. One can use the STIX type as a +top-level configuration key, and map to type-specific settings: + +.. code:: JSON + + { + "": { + "displayProperty": "", + "displayIcon": "", + "embeddedRelationships": ["...relationships..."] + } + } + +The meanings are as follows: + +- ``displayProperty`` names a top-level property of STIX objects of the given + type. The value of that property is used as the graph node label for nodes + corresponding to STIX objects of this type. If an + `object-specific `_ label is given, it will + override this setting. +- ``displayIcon`` gives a URL to an icon file to use for this STIX type. This + would be most relevant for graphical visualizations which use icons. Both + library included views use this to create a legend: the legend will display + icons even though the list view is textual. +- ``embeddedRelationships`` describes what embedded relationships should be + converted to graph edges, and how to create the edges. The value of this + property is an array of length-three arrays:: + + ["", "", true] + + The first element is a `property path `_ which should + identify a _ref(s) property in objects of that type. The second is a label + to use for the edges, and the third element is a boolean which determines the + directionality of the resulting edges. If ``true``, the edge direction will + be referrer -> referent; otherwise, the direction will be the reverse. + +``displayProperty`` and ``embeddedRelationships`` are used only when creating +graph data. ``displayIcon`` is used only in the views. + + +Per STIX Object Settings +------------------------ + +There is one config section which contains object-specific settings: +``userLabels``. It allows users to directly label individual STIX objects. +It is a mapping from STIX ID to label: + +.. code:: JSON + + { + "userLabels": { + "identity--349fbdc2-959e-4f76-9a44-256e226419ba": "Bob" + } + } + +This overrides per-type label settings. + +STIX Object Filtering +--------------------- + +It is possible to include or exclude STIX objects from being used to create +graph data, on the basis of some criteria: + +.. code:: JSON + + { + "include": "", + "exclude": "" + } + +``include`` is used to describe which STIX objects to include; ``exclude`` +is used to describe which STIX objects to exclude. Users can choose one of +these, depending on what is most natural for their usage. It is also possible +to include both settings. If both are included, STIX objects are included +which match ``include`` *and* do not match ``exclude``. + +How to express the criteria is described in the next section. + +STIX Object Match Criteria +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These criteria are intended to support a true/false match capability on STIX +objects. The design is based on Mongo queries, but is not the same. + +A set of criteria is expressed as a mapping. Each entry in the mapping +represents sub-criteria, and the sub-criteria represented by all map entries +are implicitly AND'd. At the top level, property path criteria and logical +operators are most useful. At nested levels, one can use value and presence +criteria as well. + +Value Criteria +^^^^^^^^^^^^^^ + +Value criteria express a comparison directly on some value. With respect to +STIX objects, this type of criteria is not useful at the top level because +useful checks against whole objects (and arrays) are not defined. They are +only useful at nested levels, applied to simple values. Value criteria can be +given as a plain value, which acts as an equality check, or a mapping with an +operator which maps to an operand value. + +For example: + +.. code:: JSON + + { "$gt": 80 } + +An example of usage of the above value criterion is: + +.. code:: JSON + + { + "confidence": { "$gt": 80 } + } + +This matches objects with a confidence value greater than 80. One could use +``$eq`` to perform an equality check, or use 80 directly as the value +criterion, which means the same thing. + +Supported value criterion operators include: ``$eq``, ``$gt``, ``$gte``, +``$in``, ``$lt``, ``$lte``, ``$ne``, ``$nin``. ``$in`` and ``$nin`` must map +to arrays, since they mean "in" and "not in" the given array. + +Property Path Criteria +^^^^^^^^^^^^^^^^^^^^^^ + +A property path criterion maps a property path to some sub-criteria. The +property path acts as a kind of "selector" of values from the object (or some +sub-object). These values are checked against the sub-criteria, and the +results are OR'd. + +For example, given object: + +.. code:: JSON + + { + "foo": [ + {"bar": 1}, + {"bar": 2} + ] + } + +Criteria: + +.. code:: JSON + + { + "foo.bar": 1 + } + +will produce a match. The "foo.bar" property path selects values 1 and 2, and +1 as the mapped criterion is a value sub-criterion which acts as a direct +equality check with that value (using the Javascript "===" operator). These +checks are implicitly OR'd, so the net result is equivalent to +(1 === 1 || 2 === 1). + +Logical Operator Criteria +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Logical operator criteria are map entries with keys ``$and``, ``$or``, or +``$not``. They behave as one would expect: the first two must map to arrays +of criteria; the last maps to a single criterion. + +``$not`` deserves some special discussion. It causes evaluation of the mapped +criterion, and simply inverts the result. It does *not* invert any nested +operators, so it can result in subtle behavioral differences as compared to +an inverted operator. For example, given object: + +.. code:: JSON + + { + "foo": [1, 2] + } + +The following criteria produce results as shown: + +- :code:`{"foo": {"$in": [2, 3]}}`: match +- :code:`{"foo": {"$nin": [2, 3]}}`: match +- :code:`{"foo": {"$not": {"$in": [2, 3]}}}`: no match + +The first is equivalent to (1 in [2, 3]) OR (2 in [2, 3]), which is true; the +second is equivalent to (1 not in [2, 3]) OR (2 not in [2, 3]) which is also +true; and the last is the inversion of the first, so it is false. + +The second is checking for a value not in [2, 3], whereas the last is +effectively ensuring that *none* of the values are in [2, 3], and those are +different criteria. + +Presence Criteria +^^^^^^^^^^^^^^^^^ + +There is one presence criterion, which occurs when a map key is ``$exists``. +It only makes sense nested directly under a property path criterion, and is +intended to act as a property presence check. It can't be quite that simple +though, because a property *path* isn't as simple as a plain property name. +So this criterion has a more general behavior, but acts as expected when the +property path has only one component (is just a property name). + +The ``$exists`` key must map to a boolean. If it maps to true, the criterion +matches if the property path selects any values from the object. If it maps +to false, the criterion matches if the property path selects nothing from the +object. + +For example, given object: + +.. code:: JSON + + { + "foo": [ + {"bar": 1}, + {"bar": 2} + ] + } + +The following criteria produce results as shown: + +- :code:`{"foo": {"$exists": true}}`: match +- :code:`{"bar": {"$exists": false}}`: match +- :code:`{"foo.bar": {"$exists": false}}`: no match + +Property Paths +-------------- + +Property paths are used in various places in the configuration settings to +identify parts of STIX objects. They can also be seen as "selectors" of +sub-components of a given object. If array-valued properties are present, a +single property path may identify multiple parts of the same object. Property +paths are designed to be transparent to arrays. There is no way to identify a +particular element of an array in a property path. + +Property paths are strings in a particular syntax. That syntax is a sequence +of property names separated by dots. Property paths shouldn't begin or end +with a dot, there should be no adjacent dots, and the path should not be empty. +Property names can't contain dots; there is no escaping. But this should be +okay since STIX property names should not contain dots. + +For example, given structure: + +.. code:: JSON + + { + "a": [ + {"b": 1}, + { + "b": { + "c": [2, 3] + } + } + ] + } + +Paths and selected sub-components include: + +- "a" -> {"b": 1}, {"b": {"c": [2, 3]}} (two results) +- "a.b" -> 1, {"c": [2, 3]} (two results) +- "a.b.c" -> 2, 3 (two results) +- "a.b.c.d" -> (no results) + +Notice that if the property path refers to an array (i.e. the last path +component names an array-valued property), it does not select the array itself, +it selects the individual elements of the array. If those individual elements +are themselves arrays, they are presently *not* searched. I.e. if the path +refers to an array, the resulting selection is not as if the array was +flattened. diff --git a/application.css b/application.css index dd9b108..d3a4957 100644 --- a/application.css +++ b/application.css @@ -1,46 +1,32 @@ -html, body { - padding: 0; +body { margin: 0; font-family: "Helvetica", sans-serif; - font-weight: 300; } -ul li div { - height: 37px; - display: inline-block; - vertical-align: middle; +#top-header-bar +{ + background-color: #fafafa; + margin-bottom: 10px; + padding: 20px; } -h1 { +#top-header-bar h1 +{ font-size: 36px; - font-weight: 300; - background-color: #fafafa; - margin: 0; - padding: 20px; -} - -h1 img { - position: absolute; - width: 32px; - height: 32px; - top: 25px; - right: 25px; - border: 0; + font-weight: normal; + display: inline; } -#uploader, #canvas-container { - position: absolute; - top: 81px; - bottom: 0; - left: 0; - right: 0; - padding-bottom: 25px; - width: auto; +#top-header-bar a +{ + float: right; } -svg { +#canvas { border: solid 1px black; - width: auto; + width: 100%; + height: 600px; + overflow: auto; } #uploader { @@ -60,90 +46,87 @@ svg { } #canvas-wrapper { - margin: 25px; + margin: 0 10px 0 10px; float: left; - width: auto; + width: 60%; } .sidebar { - right: 25px; - width: 350px; - float: right; + width: 34%; + display: inline-block; + vertical-align: top; + margin-bottom: 10px; + padding: 25px; + background-color: #fafafa; + overflow: auto; } .sidebar h2 { + font-weight: normal; text-align: center; - margin-top: 0; } -#legend { - margin-top: 25px; - padding: 25px; - background-color: #fafafa; +#legend-content { + width: 100%; + border-collapse: collapse; } -#selected { - margin-top: 25px; - background-color: #fafafa; - cursor: pointer; - float: left; - width: 50%; +#legend-content img { + vertical-align: middle; + margin-right: 0.5em; } -#linkedNodesGroup { - margin-top: 25px; - background-color: #fafafa; - cursor: pointer; - float: right; - width: 50%; +#legend-content td { + width: 50%; } -div.top-wrapper { - display: table-row; - width: 100%; +#legend-content .typeHidden { + font-style: italic; + color: gray; } -div.wrapper > div > .type { - width: 30%; - font-weight: bold; +.selected-object-object-content { + margin-left: 2em; /* affects indentation of object properties */ } -div.wrapper { - margin-left: 2em; - width: 100%; - overflow-wrap: break-word; - display: inline-block; +.selected-object-prop-name { + color: purple; + margin-right: 0.5em; /* space out prop name from its value. */ } -span.title { - font-weight: normal; - color: purple; +.selected-object-list { + padding-left: 2em; /* affects indentation of list items */ + margin: 0; /* snug lists up with what's above/below them */ + list-style: none; /* drop the list item numbering */ } -span.num_value { - color: orange; - cursor: text; +.selected-object-text-value { + color: darkblue; } -span.text_value { - color: darkblue; - cursor: text; +.selected-object-text-value-ref { + color: lightblue; + text-decoration: underline; + cursor: pointer; } -span.ref_value_resolved { - color: lightblue; - text-decoration: underline; - cursor: context-menu; +/* If a reference is to an object which is not in the graph */ +.selected-object-text-value-ref-dangling { + color: red; + text-decoration: underline; + cursor: not-allowed; } -span.ref_value_unresolved { - color: red; - text-decoration: underline; - cursor: not-allowed; +.selected-object-nontext-value { + color: orange; } -div.wrapper > div > .value { - word-wrap: break-word; +.list-view-item { + cursor: pointer; +} + +.list-view-selected { + background-color: lightgray; } #paste-area-stix-json { @@ -152,46 +135,10 @@ div.wrapper > div > .value { } #paste-area-custom-config { - height: 25em; + height: 35em; width: 40em; } -h2 { - font-weight: 200; -} - -line, .link { - stroke-width: 5; - stroke: #aaa; -} - -ul { - list-style-type: none; - text-align: left; - padding: 0; - margin: 0; -} - -li { - padding: 5px 0px; - margin: 0; -} - - -ul > li > p { - display: inline-block; - margin: 5px 10px; -} - -.dimmed { - opacity: 0.9; -} - -.pinned { - stroke: black; - stroke-width: 5; -} - .linkish:hover { color: blue; cursor: pointer; @@ -200,8 +147,3 @@ ul > li > p { .hidden { display: none; } - -/*.selected { - >>> This style is in the header of the index.html file for comptibility reasons! <<< - filter: url("#drop-shadow"); -}*/ diff --git a/application.js b/application.js index cad916f..60d0542 100644 --- a/application.js +++ b/application.js @@ -1,5 +1,6 @@ +"use strict"; /* -Stix2viz and d3 are packaged in a way that makes them work as Jupyter +Stix2viz and visjs are packaged in a way that makes them work as Jupyter notebook extensions. Part of the extension installation process involves copying them to a different location, where they're available via a special "nbextensions" path. This path is hard-coded into their "require" module @@ -12,7 +13,7 @@ these modules and apps in a better way. */ require.config({ paths: { - "nbextensions/stix2viz/d3": "stix2viz/d3/d3" + "nbextensions/stix2viz/vis-network": "stix2viz/visjs/vis-network" } }); @@ -20,52 +21,169 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi // Init some stuff - // For optimization purposes, look into moving these to local variables - var visualizer; - selectedContainer = document.getElementById('selection'); - linkedNodes = document.getElementById('linkedNodes'); - uploader = document.getElementById('uploader'); - canvasContainer = document.getElementById('canvas-container'); - canvas = document.getElementById('canvas'); - styles = window.getComputedStyle(uploader); + let view = null; + let uploader = document.getElementById('uploader'); + let canvasContainer = document.getElementById('canvas-container'); + let canvas = document.getElementById('canvas'); - /* ****************************************************** - * Resizes the canvas based on the size of the window - * ******************************************************/ - function resizeCanvas() { - const container = document.getElementById("canvas-container"); - var cWidth = container.clientWidth - document.getElementById('legend').width - 52; - var cHeight = window.innerHeight - document.getElementsByTagName('h1')[0].offsetHeight - 27; - document.getElementById('canvas-wrapper').style.width = cWidth; - canvas.style.width = cWidth; - canvas.style.height = cHeight; + /** + * Build a message and display an alert window, from an exception object. + * This will follow the exception's causal chain and display all of the + * causes in sequence, to produce a more informative message. + */ + function alertException(exc, initialMessage=null) + { + let messages = []; + + if (initialMessage) + messages.push(initialMessage); + + messages.push(exc.toString()); + + while (exc instanceof Error && exc.cause) + { + exc = exc.cause; + messages.push(exc.toString()); + } + + let message = messages.join("\n\n Caused by:\n\n"); + + alert(message); } - /* ****************************************************** - * Will be called right before the graph is built. - * ******************************************************/ - function vizCallback() { - hideMessages(); - resizeCanvas(); + + /** + * Handle clicks on the visjs graph view. + * + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + */ + function graphViewClickHandler(event, edgeDataSet, stixIdToObject) + { + if (event.nodes.length > 0) + { + // A click on a node + let stixObject = stixIdToObject.get(event.nodes[0]); + if (stixObject) + populateSelected(stixObject, edgeDataSet, stixIdToObject); + } + else if (event.edges.length > 0) + { + // A click on an edge + let stixRel = stixIdToObject.get(event.edges[0]); + if (stixRel) + populateSelected(stixRel, edgeDataSet, stixIdToObject); + else + // Just make something up to show for embedded relationships + populateSelected( + new Map([["", "(Embedded relationship)"]]), + edgeDataSet, stixIdToObject + ); + } + // else, just a click on the canvas } - /* ****************************************************** - * Will be called if there's a problem parsing input. - * ******************************************************/ - function errorCallback() { - document.getElementById('chosen-files').innerText = ""; - document.getElementById("files").value = ""; + + /** + * Handle clicks on the list view. + * + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + */ + function listViewClickHandler(event, edgeDataSet, stixIdToObject) + { + let clickedItem = event.target; + + if (clickedItem.tagName === "LI") + { + let stixId = clickedItem.id; + let stixObject = stixIdToObject.get(stixId); + + view.selectNode(stixId); + + if (stixObject) + populateSelected(stixObject, edgeDataSet, stixIdToObject); + else + // Just make something up to show for embedded relationships + populateSelected( + new Map([["", "(Embedded relationship)"]]), + edgeDataSet, stixIdToObject + ); + } } + /* ****************************************************** - * Initializes the graph, then renders it. + * Initializes the view, then renders it. * ******************************************************/ function vizStixWrapper(content, customConfig) { - cfg = { - iconDir: "stix2viz/stix2viz/icons" - } - visualizer = new stix2viz.Viz(canvas, cfg, populateLegend, populateSelected, populateList); - visualizer.vizStix(content, customConfig, vizCallback, errorCallback, 200, true); + + if (customConfig) + try + { + customConfig = JSON.parse(customConfig); + } + catch(err) + { + alertException(err, "Invalid configuration: must be JSON"); + return; + } + else + customConfig = {}; + + // Hard-coded working icon directory setting for this application. + customConfig.iconDir = "stix2viz/stix2viz/icons"; + + toggleView(); + + try + { + let [nodeDataSet, edgeDataSet, stixIdToObject] + = stix2viz.makeGraphData(content, customConfig); + + let wantsList = false; + if (nodeDataSet.length > 200) + wantsList = confirm( + "This graph contains " + nodeDataSet.length.toString() + + " nodes. Do you wish to display it as a list?" + ); + + if (wantsList) + { + view = stix2viz.makeListView( + canvas, nodeDataSet, edgeDataSet, stixIdToObject, + customConfig + ); + + view.on( + "click", + e => listViewClickHandler(e, edgeDataSet, stixIdToObject) + ); + } + else + { + view = stix2viz.makeGraphView( + canvas, nodeDataSet, edgeDataSet, stixIdToObject, + customConfig + ); + + view.on( + "click", + e => graphViewClickHandler(e, edgeDataSet, stixIdToObject) + ); + } + + populateLegend(...view.legendData); + } + catch (err) + { + console.log(err); + alertException(err); + } } /* ----------------------------------------------------- * @@ -93,7 +211,7 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi for (var i = 0, f; f = files[i]; i++) { document.getElementById('chosen-files').innerText += f.name + " "; - customConfig = document.getElementById('paste-area-custom-config').value; + let customConfig = document.getElementById('paste-area-custom-config').value; var r = new FileReader(); r.onload = function(e) {vizStixWrapper(e.target.result, customConfig);}; r.readAsText(f); @@ -106,8 +224,8 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi * Handles content pasted to the text area. * ******************************************************/ function handleTextarea() { - customConfig = document.getElementById('paste-area-custom-config').value; - content = document.getElementById('paste-area-stix-json').value; + let customConfig = document.getElementById('paste-area-custom-config').value; + let content = document.getElementById('paste-area-stix-json').value; vizStixWrapper(content, customConfig); linkifyHeader(); } @@ -119,186 +237,425 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi * ******************************************************/ function handleFetchJson() { var url = document.getElementById("url").value; - customConfig = document.getElementById('paste-area-custom-config').value; + let customConfig = document.getElementById('paste-area-custom-config').value; fetchJsonAjax(url, function(content) { vizStixWrapper(content, customConfig); }); linkifyHeader(); } + /** + * Toggle the display of graph nodes of a particular STIX type. + */ + function legendClickHandler(event) + { + if (!view) + return; + + let td; + let clickedTagName = event.target.tagName.toLowerCase(); + + if (clickedTagName === "td") + // ... if the legend item text was clicked + td = event.target; + else if (clickedTagName === "img") + // ... if the legend item icon was clicked + td = event.target.parentElement; + else + return; + + // The STIX type the user clicked on + let toggledStixType = td.textContent.trim().toLowerCase(); + + view.toggleStixType(toggledStixType); + + // style change to remind users what they've hidden. + td.classList.toggle("typeHidden"); + } + /* ****************************************************** * Adds icons and information to the legend. - * - * Takes an array of type names as input * ******************************************************/ - function populateLegend(typeGroups) { - var ul = document.getElementById('legend-content'); - var color = d3.scale.category20(); - typeGroups.forEach(function(typeName, index) { - var li = document.createElement('li'); - var val = document.createElement('p'); - var key = document.createElement('div'); - var keyImg = document.createElement('img'); - keyImg.onerror = function() { - // set the node's icon to the default if this image could not load - this.src = visualizer.d3Config.iconDir + "/stix2_custom_object_icon_tiny_round_v1.svg"; + function populateLegend(iconURLMap, defaultIconURL) { + let tbody, tr, td; + let colIdx = 0; + let table = document.getElementById('legend-content'); + + // Reset table content if necessary. + if (table.tBodies.length === 0) + tbody = table.createTBody(); + else + tbody = table.tBodies[0]; + + tbody.replaceChildren(); + + tr = tbody.insertRow(); + + for (let [stixType, iconURL] of iconURLMap) + { + let img = document.createElement('img'); + + img.onerror = function() { + // set the node's icon to the default if this image could not + // load + this.src = defaultIconURL; + // our default svg is enormous... shrink it down! + this.width = "37"; + this.height = "37"; + } + img.src = iconURL; + + if (colIdx > 1) + { + colIdx = 0; + tr = tbody.insertRow(); + } + + td = tr.insertCell(); + ++colIdx; + + td.append(img); + td.append(stixType.charAt(0).toUpperCase() + stixType.substr(1).toLowerCase()); } - keyImg.src = visualizer.iconFor(typeName); - keyImg.width = "37"; - keyImg.height = "37"; - keyImg.style.background = "radial-gradient(" + color(index) + " 16px,transparent 16px)"; - key.appendChild(keyImg); - val.innerText = typeName.charAt(0).toUpperCase() + typeName.substr(1).toLowerCase(); // Capitalize it - li.appendChild(key); - li.appendChild(val); - ul.appendChild(li); - }); } - /* ****************************************************** - * Adds information to the selected node table. - * - * Takes datum as input - * ******************************************************/ - function populateSelected(d) { - // Remove old values from HTML - selectedContainer.innerHTML = ""; - linkedNodes.innerHTML = "
    "; - populateParent(selectedContainer, d, 0); - const links = visualizer.linkMap[d.id]; - - // build out the list of all linked objects to the current one - for(let i = 0; i < links.length; i++) { - const cur = visualizer.objectMap[links[i].target]; - let name = links[i].type + ": " + cur.type; - - if(links[i].flip) { - text = cur.type + " had " + links[i].type; + /** + * A JSON.stringify() replacer function to enable it to handle Map objects + * like plain javascript objects. + */ + function mapReplacer(key, value) + { + if (value instanceof Map) + { + let plainObj = {}; + for (let [subKey, subValue] of value) + plainObj[subKey] = subValue; + + value = plainObj; } - addListElement(linkedNodes.firstChild, cur, name, "mainList"); - } + return value; } - function populateParent(parent, d) { - Object.keys(d).forEach(function(key) { - // skip embedded in a basic pass since they should be handled by a call directly to that array - if(key == "__embeddedLinks" || key == "__isEmbedded") { - return; + /** + * Create a rendering of an array as part of rendering an overall STIX + * object. + * + * @param arrayContent The array to render + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + * @param isRefs Whether the array is the value of a _refs property, i.e. + * an array of STIX IDs. Used to produce a distinctive rendering for + * references. + * @return The rendering as an array of DOM elements + */ + function stixArrayContentToDOMNodes( + arrayContent, edgeDataSet, stixIdToObject, isRefs=false + ) + { + let nodes = []; + + let ol = document.createElement("ol"); + ol.className = "selected-object-list"; + + for (let elt of arrayContent) + { + let contentNodes; + if (isRefs) + contentNodes = stixStringContentToDOMNodes( + elt, edgeDataSet, stixIdToObject, /*isRef=*/true + ); + else + contentNodes = stixContentToDOMNodes( + elt, edgeDataSet, stixIdToObject + ); + + let li = document.createElement("li"); + li.append(...contentNodes); + ol.append(li); } - // Create new, empty HTML elements to be filled and injected - const title = document.createElement('span'); - const wrapper = document.createElement('div'); - const isRef = key.endsWith("_ref") || key.endsWith("_refs"); // controls the style of children and if they should be clickable - - title.classList.add("title"); - title.innerText = key + ": "; - wrapper.classList.add("wrapper"); - wrapper.appendChild(title); - - // Add the text to the new inner html elements - var value = d[key]; - - if(Array.isArray(value) && value.length > 0) { - const open = document.createElement('span'); - open.innerText = "["; - wrapper.appendChild(open); - if(typeof value[0] === "object" && value[0] !== null) { - for(let i = 0; i < value.length; i++) { - const subWrapper = document.createElement('div'); - subWrapper.classList.add("wrapper"); - - const openObj = document.createElement('span'); - openObj.innerText = "{"; - subWrapper.appendChild(openObj); - populateParent(subWrapper, value[i]); - - const closeObj = document.createElement('div'); - closeObj.innerText = "}"; - subWrapper.appendChild(closeObj); - - wrapper.appendChild(subWrapper); - } - } - else { - - - for(let i = 0; i < value.length; i++) { - const subWrapper = document.createElement('div'); - subWrapper.classList.add("wrapper"); - const element = value[i]; - const val = document.createElement('span'); - if(isRef) { - if(element in visualizer.objectMap) { - val.classList.add("ref_value_resolved"); - val.onclick = function() { - populateByUUID(element) - selectedContainer.scrollIntoView(); - } - } - else { - val.classList.add("ref_value_unresolved"); - } - } - else if(typeof element == "string") { - val.classList.add("text_value"); - } - else { - val.classList.add("num_value"); - } - - val.innerText = element; - val.classList.add("value"); - subWrapper.appendChild(val); - wrapper.appendChild(subWrapper); - } - } + nodes.push(document.createTextNode("[")); + nodes.push(ol); + nodes.push(document.createTextNode("]")); + + return nodes; + } - const close = document.createElement('span'); - close.innerText = "]"; - wrapper.appendChild(close); + /** + * Create a rendering of an object/dictionary as part of rendering an + * overall STIX object. + * + * @param objectContent The object/dictionary to render, as a Map instance + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + * @param topLevel Whether objectContent is itself a whole STIX object, + * i.e. the top level of a content tree. This is used to adjust the + * rendering, e.g. omit the surrounding braces at the top level. + * @return The rendering as an array of DOM elements + */ + function stixObjectContentToDOMNodes( + objectContent, edgeDataSet, stixIdToObject, topLevel=false + ) + { + let nodes = []; + + if (!topLevel) + nodes.push(document.createTextNode("{")); + + for (let [propName, propValue] of objectContent) + { + let propNameSpan = document.createElement("span"); + propNameSpan.className = "selected-object-prop-name"; + propNameSpan.append(propName + ":"); + + let contentNodes; + if (propName.endsWith("_ref")) + contentNodes = stixStringContentToDOMNodes( + propValue, edgeDataSet, stixIdToObject, /*isRef=*/true + ); + else if (propName.endsWith("_refs")) + contentNodes = stixArrayContentToDOMNodes( + propValue, edgeDataSet, stixIdToObject, /*isRefs=*/true + ); + else + contentNodes = stixContentToDOMNodes( + propValue, edgeDataSet, stixIdToObject + ); + + let propDiv = document.createElement("div"); + propDiv.append(propNameSpan); + propDiv.append(...contentNodes); + + if (!topLevel) + propDiv.className = "selected-object-object-content"; + + nodes.push(propDiv); } - else if(typeof value === "object" && value !== null) { - const openObj = document.createElement('span'); - openObj.innerText = "{"; - wrapper.appendChild(openObj); - populateParent(wrapper, value); - - const closeObj = document.createElement('span'); - closeObj.innerText = "}"; - wrapper.appendChild(closeObj); + + if (!topLevel) + nodes.push(document.createTextNode("}")); + + return nodes; + } + + /** + * Create a rendering of a string value as part of rendering an overall + * STIX object. + * + * @param stringContent The string to render + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + * @param isRef Whether the string is the value of a _ref property. Used + * to produce a distinctive rendering for references. + * @return The rendering as an array of DOM elements + */ + function stixStringContentToDOMNodes( + stringContent, edgeDataSet, stixIdToObject, isRef=false + ) + { + let nodes = []; + + let spanWrapper = document.createElement("span"); + spanWrapper.append(stringContent); + + if (isRef) + { + let referentObj = stixIdToObject.get(stringContent); + if (referentObj) + { + spanWrapper.className = "selected-object-text-value-ref"; + spanWrapper.addEventListener( + "click", e => { + e.stopPropagation(); + view.selectNode(referentObj.get("id")); + populateSelected( + referentObj, edgeDataSet, stixIdToObject + ); + } + ); + } + else + spanWrapper.className = "selected-object-text-value-ref-dangling"; } - else { - const val = document.createElement('span'); - if(isRef) { - if(value in visualizer.objectMap) { - val.classList.add("ref_value_resolved"); - val.onclick = function() { - populateByUUID(value); - selectedContainer.scrollIntoView(); - } + else + spanWrapper.className = "selected-object-text-value"; + + nodes.push(spanWrapper); + + return nodes; + } + + /** + * Create a rendering of a value for which no other special rendering + * applies, as part of rendering an overall STIX object. + * + * @param otherContent The content to render + * @return The rendering as an array of DOM elements + */ + function stixOtherContentToDOMNodes(otherContent) + { + let nodes = []; + + let asText; + if (otherContent === null) + asText = "null"; + else if (otherContent === undefined) + asText = "undefined"; // also just in case?? + else + asText = otherContent.toString(); + + let spanWrapper = document.createElement("span"); + spanWrapper.append(asText); + spanWrapper.className = "selected-object-nontext-value"; + nodes.push(spanWrapper); + + return nodes; + } + + /** + * Create a rendering of a value, as part of rendering an overall STIX + * object. This function dispatches to one of the more specialized + * rendering functions based on the type of the value. + * + * @param stixContent The content to render + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + * @return The rendering as an array of DOM elements + */ + function stixContentToDOMNodes(stixContent, edgeDataSet, stixIdToObject) + { + let nodes; + + if (stixContent instanceof Map) + nodes = stixObjectContentToDOMNodes( + stixContent, edgeDataSet, stixIdToObject + ); + else if (Array.isArray(stixContent)) + nodes = stixArrayContentToDOMNodes( + stixContent, edgeDataSet, stixIdToObject + ); + else if ( + typeof stixContent === "string" || stixContent instanceof String + ) + nodes = stixStringContentToDOMNodes( + stixContent, edgeDataSet, stixIdToObject + ); + else + nodes = stixOtherContentToDOMNodes(stixContent); + + return nodes; + } + + /** + * Populate the Linked Nodes box with the connections of the given STIX + * object. + * + * @param stixObject The STIX object to display connection information + * about + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + */ + function populateConnections(stixObject, edgeDataSet, stixIdToObject) + { + let objId = stixObject.get("id"); + + let edges = edgeDataSet.get({ + filter: item => (item.from === objId || item.to === objId) + }); + + let eltConnIncoming = document.getElementById("connections-incoming"); + let eltConnOutgoing = document.getElementById("connections-outgoing"); + + eltConnIncoming.replaceChildren(); + eltConnOutgoing.replaceChildren(); + + let listIn = document.createElement("ol"); + let listOut = document.createElement("ol"); + + eltConnIncoming.append(listIn); + eltConnOutgoing.append(listOut); + + for (let edge of edges) + { + let targetList; + let summaryNode = document.createElement("summary"); + let otherEndSpan = document.createElement("span"); + let otherEndObj; + + if (objId === edge.from) + { + otherEndObj = stixIdToObject.get(edge.to); + otherEndSpan.append(otherEndObj.get("type")); + + summaryNode.append(edge.label + " "); + summaryNode.append(otherEndSpan); + + targetList = listOut; } - else { - val.classList.add("ref_value_unresolved"); + else + { + otherEndObj = stixIdToObject.get(edge.from); + otherEndSpan.append(otherEndObj.get("type")); + + summaryNode.append(otherEndSpan); + summaryNode.append(" " + edge.label); + + targetList = listIn; } - } - else if(typeof value == "string") { - val.classList.add("text_value"); - } - else { - val.classList.add("num_value"); - } - - val.innerText = value; - val.classList.add("value"); - wrapper.appendChild(val); + + otherEndSpan.className = "selected-object-text-value-ref"; + otherEndSpan.addEventListener( + "click", e => { + view.selectNode(otherEndObj.get("id")); + populateSelected(otherEndObj, edgeDataSet, stixIdToObject); + } + ); + + let li = document.createElement("li"); + let detailsNode = document.createElement("details"); + + targetList.append(li); + li.append(detailsNode); + detailsNode.append(summaryNode); + + let objRenderNodes = stixObjectContentToDOMNodes( + otherEndObj, edgeDataSet, stixIdToObject, /*topLevel=*/true + ); + detailsNode.append(...objRenderNodes); } + } - // Add new divs to "Selected Node" - - parent.appendChild(wrapper); - }); + /** + * Populate relevant webpage areas according to a particular STIX object. + * + * @param stixObject The STIX object to display information about + * @param edgeDataSet A visjs DataSet instance with graph edge data derived + * from STIX content + * @param stixIdToObject A Map instance mapping STIX IDs to STIX objects as + * Maps, containing STIX content. + */ + function populateSelected(stixObject, edgeDataSet, stixIdToObject) { + // Remove old values from HTML + let selectedContainer = document.getElementById('selection'); + selectedContainer.replaceChildren(); + + let contentNodes = stixObjectContentToDOMNodes( + stixObject, edgeDataSet, stixIdToObject, /*topLevel=*/true + ); + selectedContainer.append(...contentNodes); + + populateConnections(stixObject, edgeDataSet, stixIdToObject); } function populateByUUID(uuid) { @@ -359,10 +716,10 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi } /* ****************************************************** - * Hides the data entry container and displays the graph - * container + * Toggle the view between the data entry container and + * the view container * ******************************************************/ - function hideMessages() { + function toggleView() { uploader.classList.toggle("hidden"); canvasContainer.classList.toggle("hidden"); } @@ -381,13 +738,30 @@ require(["domReady!", "stix2viz/stix2viz/stix2viz"], function (document, stix2vi function resetPage() { var header = document.getElementById('header'); if (header.classList.contains('linkish')) { - hideMessages(); - visualizer.vizReset(); + toggleView(); + if (view) + { + view.destroy(); + view = null; + } document.getElementById('files').value = ""; // reset the files input document.getElementById('chosen-files').innerHTML = ""; // reset the subheader text - document.getElementById('legend-content').innerHTML = ""; // reset the legend in the sidebar document.getElementById('selection').innerHTML = ""; // reset the selected node in the sidebar + // Reset legend table + let table = document.getElementById('legend-content'); + if (table.tBodies.length > 0) + { + let tbody = table.tBodies[0]; + tbody.replaceChildren(); + } + + // reset connections box + let eltConnIncoming = document.getElementById("connections-incoming"); + let eltConnOutgoing = document.getElementById("connections-outgoing"); + eltConnIncoming.replaceChildren(); + eltConnOutgoing.replaceChildren(); + header.classList.remove('linkish'); } } @@ -442,7 +816,7 @@ is not serving JSON, or is not running a webserver.\n\nA GitHub Gist can be crea var res = regex.exec(url); if (res != null) { // Get the value from the `url` parameter - req_url = res[0].substring(5); + let req_url = res[0].substring(5); // Fetch JSON from the url fetchJsonAjax(req_url, function(content) { @@ -457,13 +831,13 @@ is not serving JSON, or is not running a webserver.\n\nA GitHub Gist can be crea } function selectedNodeClick() { - selected = document.getElementById('selected'); + let selected = document.getElementById('selected'); if (selected.className.indexOf('clicked') === -1) { selected.className += " clicked"; selected.style.position = 'absolute'; selected.style.left = '25px'; - selected.style.width = window.innerWidth - 110; - selected.style.top = document.getElementById('legend').offsetHeight + 25; + selected.style.width = (window.innerWidth - 110) + "px"; + selected.style.top = (document.getElementById('canvas').offsetHeight + 25) + "px"; selected.scrollIntoView(true); } else { selected.className = "sidebar" @@ -480,6 +854,8 @@ is not serving JSON, or is not running a webserver.\n\nA GitHub Gist can be crea document.getElementById('header').addEventListener('click', resetPage, false); uploader.addEventListener('dragover', handleDragOver, false); uploader.addEventListener('drop', handleFileDrop, false); - window.onresize = resizeCanvas; + document.getElementById('selected').addEventListener('click', selectedNodeClick, false); + document.getElementById("legend").addEventListener("click", legendClickHandler, {capture: true}); + fetchJsonFromUrl(); }); diff --git a/index.html b/index.html index 4d74798..597538f 100644 --- a/index.html +++ b/index.html @@ -1,3 +1,4 @@ + @@ -5,17 +6,17 @@ - -

    STIX Visualizer - View source on GitHub

    +
    +

    + STIX Visualizer +

    + + View source on GitHub + +

    Drop some STIX 2.x here!

    @@ -32,37 +33,32 @@

    STIX Visualizer

    -- Configuration --

    - +