webui: initial mockup of capa-webui

The following commits introduces capa-webui, a new web-based tool
to render capa existing output format - the result document.

The current project structure is as follows -
- webui/index.html: is the main HTML file, that serves as the entry
point for the web application.
- scripts/main.js: includes event handlers, DOM manipulation code
- webui/assets/css/styles.css: contains the styles for the UI

Webui is meant to be used a standalone static site, though for now
we are splitting this into multiple souce files for ease of use.
We can release a standalone static index.html in the releases.

This initial draft is subject to major structrual changes

Webui is meant to be deployed using github-pages
This commit is contained in:
Soufiane Fariss
2024-06-19 23:33:06 +02:00
parent 1975b6455c
commit 413047a7e8
3 changed files with 827 additions and 0 deletions

355
webui/assets/css/style.css Executable file
View File

@@ -0,0 +1,355 @@
/*
* Copyright (C) 2023 Mandiant, Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at: [package root]/LICENSE.txt
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and limitations under the License.
*/
/* TODO(s-ff): simplfy and refactor the CSS styling */
/* TODO(s-ff): change font, do not use Product Sans */
@font-face {
font-family: 'Product Sans';
font-style: normal;
src: local('Open Sans'), local('OpenSans'), url(https://fonts.gstatic.com/s/productsans/v5/HYvgU2fE2nRJvZ5JFAumwegdm0LZdjqr5-oayXSOefg.woff2) format('woff2');
}
/* Reset and general styles */
*,
*::before,
*::after {
box-sizing: border-box;
}
input {
font: inherit;
}
fieldset {
padding: 16px;
display: grid;
gap: 16px;
}
legend {
font-weight: 700;
}
html {
font-family: 'Product Sans', sans-serif;
}
body {
max-width: 95%;
margin: 0 auto;
padding: 8px;
}
/* Tree styles */
.tree {
--font-size: 1rem;
--line-height: 1.75;
--spacing: calc(var(--line-height) * 1em);
--thickness: 0.35px;
--radius: 8px;
line-height: var(--line-height);
font-size: var(--font-size);
}
.tree li {
display: block;
position: relative;
padding-left: calc(2 * var(--spacing) - var(--radius) - var(--thickness));
}
.tree ul {
margin-left: calc(var(--radius) - var(--spacing));
padding-left: 0;
}
.tree ul li {
border-left: var(--thickness) solid grey;
}
.tree ul li:last-child {
border-color: transparent;
}
.tree ul li::before {
content: "";
display: block;
position: absolute;
top: calc(var(--spacing) / -2);
left: calc(-1 * var(--thickness));
width: calc(var(--spacing) + var(--thickness));
height: calc(var(--spacing) + var(--thickness) / 2);
border: solid grey;
border-width: 0 0 var(--thickness) var(--thickness);
}
.tree summary {
display: block;
cursor: pointer;
}
.tree summary::marker,
.tree summary::-webkit-details-marker {
display: none;
}
.tree summary:focus {
outline: none;
}
.tree summary:focus-visible {
outline: var(--thickness) dotted #000;
}
.tree li::after,
.tree summary::before {
content: "";
display: block;
position: absolute;
top: calc(var(--spacing) / 2 - var(--radius));
left: calc(var(--spacing) - var(--radius) - var(--thickness) / 2);
width: calc(2 * var(--radius));
height: calc(2 * var(--radius));
border-radius: 50%;
background: #ddd;
}
.tree summary::before {
content: "+";
z-index: 1;
background: #7fa0bb;
color: white;
line-height: calc(2 * var(--radius));
text-align: center;
}
.tree summary .summary-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.tree summary .summary-content .statement-type,
.feature-type {
background-color: #f0f0f0;
border-radius: 4px;
color: #000;
display: inline-block;
font-size: 0.7em;
padding: 1px 6px;
cursor: pointer;
}
.tree summary .summary-content .rule-title,
.feature-value {
margin-left: 1rem;
flex-grow: 1;
}
.feature-value {
color: green;
}
.feature-location {
margin-left: 5px;
font-size: 0.8em;
color: #000;
}
.tree summary .summary-content .namespace {
background-color: #f0f0f0;
border-radius: 4px;
color: #000;
display: inline-block;
font-size: 0.75em;
padding: 2px 6px;
cursor: pointer;
content: "";
}
.tree details[open] > summary::before {
content: "";
}
/* Rule source tooltip styles */
.tree .rule-title {
position: relative;
}
.tree .rule-title:hover::after {
content: attr(source);
font-family: 'Courier New', monospace;
font-size: 0.7em;
line-height: 1.2em;
color: black;
width: max-content;
padding: 8px;
border-radius: 6px;
background: #eee;
position: absolute;
left: 350px;
z-index: 1000;
white-space: pre-wrap;
opacity: 0;
}
.tree .rule-title:hover::after {
opacity: 1;
}
.tree .rule-title:hover::before {
display: none;
}
/* Controls styles */
.controls {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.hover-toggle-label {
margin-right: 20px;
}
#searchContainer {
flex-grow: 1;
}
#searchContainer input {
width: 100%;
padding: 5px;
}
/* Metadata table styles */
.metadata-container {
margin-bottom: 20px;
}
.metadata-table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
}
.metadata-table td {
border: 1px solid #ddd;
padding: 8px;
}
.metadata-table tr:nth-child(even) {
background-color: #f2f2f2;
}
.metadata-table tr:hover {
background-color: #ddd;
}
.metadata-table td:first-child {
font-weight: bold;
width: 140px;
}
/* Banner styles */
.banner {
background-color: #007bff;
margin: 7px 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 15px;
border-radius: 5px;
text-align: left;
color: #fff;
font-size: 14px;
font-family: 'Product-Sans', sans-serif;
cursor: initial;
}
.bannerContent {
padding-right: 15px;
}
.bannerLinks {
display: flex;
align-items: center;
border-left: 2px solid #ffffffad;
padding-left: 15px;
cursor: initial;
}
.banner a {
display: inline;
color: #fff;
text-decoration-color: #fff;
}
.banner a:hover {
color: #343a40;
opacity: 0.8;
}
/* Matched rule styles */
.matched-rule li {
position: relative;
}
.matched-rule .feature-location {
position: absolute;
right: 580px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8em;
color: #000;
}
/* Tree table styles */
.tree-table {
width: 100%;
border-collapse: collapse;
}
.tree-table th {
padding: 8px;
text-align: left;
vertical-align: top;
}
.tree-table th {
background-color: #f0f0f0;
font-weight: bold;
}
.tree-table th:nth-child(1) {
width: 60%;
}
.tree-table th:nth-child(2),
.tree-table th:nth-child(3) {
width: 20%;
border-left: 1px solid #ddd;
}
.tree-table .tree {
margin-top: 0;
}
/* Button styles */
button {
background-color: #f0f0f0;
border: none;
border-radius: 4px;
color: #000;
cursor: pointer;
font-size: 0.9em;
margin-left: 10px;
padding: 5px 10px;
}

142
webui/index.html Executable file
View File

@@ -0,0 +1,142 @@
<!--
Copyright (C) 2024 Mandiant, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at: [package root]/LICENSE.txt
Unless required by applicable law or agreed to in writing, software distributed under the License
is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
-->
<html>
<head>
<!-- TODO(s-ff): include a favicon -->
<title>CAPA WebUI</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="assets/css/style.css">
<script src="scripts/main.js"> </script>
<script>
// TODO(s-ff): the search now only search for feature value, fix it to
// to also search for the rule titles.
// unstable
function searchTree(node, keyword) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList.contains('rule-title') || node.classList.contains('feature-value')) {
const textContent = node.textContent.toLowerCase();
if (textContent.includes(keyword.toLowerCase())) {
node.closest('.matched-rule').style.display = 'block';
expandParentDetails(node);
} else {
node.closest('.matched-rule').style.display = 'none';
}
}
for (const child of node.childNodes) {
searchTree(child, keyword);
}
}
}
function expandParentDetails(node) {
const parentDetails = node.closest('details');
if (parentDetails) {
parentDetails.open = true;
expandParentDetails(parentDetails.parentNode);
}
}
function collapseAllDetails() {
const detailsElements = document.querySelectorAll('details');
detailsElements.forEach(details => {
details.open = false;
});
}
function expandAllDetails() {
const detailsElements = document.querySelectorAll('details');
detailsElements.forEach(details => {
details.open = true;
});
}
function toggleExpandCollapse() {
const togglebutton = document.getElementById('toggleExpand');
const tree = document.getElementById('tree');
if (togglebutton.textContent === 'Expand All') {
expandAllDetails();
togglebutton.textContent = 'Collapse All';
} else {
collapseAllDetails();
togglebutton.textContent = 'Expand All';
}
}
// unstable
function search(value) {
const tree = document.getElementById('tree');
if (value.trim() === '') {
// If the search bar is cleared, reset the tree to its initial state
const matchedRules = document.querySelectorAll('.matched-rule');
matchedRules.forEach(rule => {
rule.style.display = 'block';
});
collapseAllDetails();
} else {
searchTree(tree, value);
}
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('toggleExpand').addEventListener('click', toggleExpandCollapse);
});
</script>
</head>
<body>
<header class="header">
<div class="banner">
<span class="bannerContent" role="alert">This is a pre-alpha release of capa-webui. Please report any bugs, enhacements, or features in the Github issues</span>
<div class="bannerLinks">
<div>
<a href="https://github.com/mandiant/capa">CAPA v7.0.1 on Github</a>
</div>
</div>
</div>
</header>
<h1></h1>
<!-- TODO(s-ff): these controls are not prefectly aligned -->
<div class="controls">
<button id="toggleExpand">Expand All</button>
<!-- TODO(s-ff): allow users to disable showing rule logic on hover -->
<label class="hover-toggle-label">
<input type="checkbox" id="enableHover" checked>
Show rule logic on hover
</label>
<div id="searchContainer">
<input onkeyup="search(this.value)" type="text" placeholder="Type to search... (unstable)" />
</div>
</div>
<table class="tree-table">
<thead>
<tr>
<th>Rule Title</th>
<th>Feature Address</th>
<th>Namespace</th>
</tr>
</thead>
</table>
<ul class="tree" id="tree">
<!-- This will contain the rendered JSON capa result document -->
</ul>
</body>
</html>

330
webui/scripts/main.js Executable file
View File

@@ -0,0 +1,330 @@
/*
* Copyright (C) 2023 Mandiant, Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at: [package root]/LICENSE.txt
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and limitations under the License.
*/
// TODO(s-ff): simply all the functions and introduce smaller helper functions
// for creating elements, i.e. createOrStatement, isRangeStatement, .. etc.
/**
* Renders the JSON data representing the CAPA results into an HTML tree structure.
* @param {Object} json - The JSON data containing the CAPA results.
*/
function renderJSON(json) {
const tree = document.getElementById('tree');
// Iterate over each rule in the JSON
for (const ruleName in json.rules) {
const rule = json.rules[ruleName];
// Create a list item for the matched rule
const ruleElement = document.createElement('li');
ruleElement.className = 'matched-rule';
ruleElement.setAttribute('name', ruleName);
// Create a details element for the rule
const ruleDetails = document.createElement('details');
// Create a summary element for the rule
const ruleSummary = document.createElement('summary');
// Create a div for the summary content
const ruleSummaryContent = document.createElement('div');
ruleSummaryContent.className = 'summary-content';
// Add the statement type, rule title, and namespace to the summary content
const statementType = document.createElement('span');
statementType.className = 'statement-type';
statementType.textContent = 'rule';
ruleSummaryContent.appendChild(statementType);
const ruleTitle = document.createElement('span');
ruleTitle.className = 'rule-title';
ruleTitle.setAttribute('source', rule.source);
ruleTitle.textContent = rule.meta.name;
ruleSummaryContent.appendChild(ruleTitle);
const namespace = document.createElement('span');
namespace.className = 'namespace';
//namespace.textContent = rule.meta.namespace;
if (rule.meta.namespace) {
namespace.textContent = rule.meta.namespace;
} else {
namespace.style.display = 'none';
}
ruleSummaryContent.appendChild(namespace);
// Append the summary content to the summary element
ruleSummary.appendChild(ruleSummaryContent);
// Append the summary element to the details element
ruleDetails.appendChild(ruleSummary);
// Create a list for the rule's matches
const matchesList = document.createElement('ul');
// Iterate over each match in the rule
for (const match of rule.matches) {
// Render the match and its children recursively
const matchElement = renderMatch(match);
matchesList.appendChild(matchElement);
}
// Append the matches list to the details element
ruleDetails.appendChild(matchesList);
// Append the rule details to the rule list item
ruleElement.appendChild(ruleDetails);
// Append the rule list item to the tree
tree.appendChild(ruleElement);
}
}
/**
* Recursively renders a match and its children into an HTML structure.
* @param {Object} match - The match object to be rendered.
* @returns {HTMLElement} The rendered match element.
*/
function renderMatch(match) {
// Create a list item for the match
const matchElement = document.createElement('li');
matchElement.className = 'match-container';
// Check if the match is a range statement
if (match[1].node.type === 'statement' && match[1].node.statement.type === 'range') {
const rangeElement = document.createElement('li');
rangeElement.className = 'match-container';
const rangeType = document.createElement('span');
rangeType.className = 'feature-type';
rangeType.textContent = `count(${match[1].node.statement.child.type}(${match[1].node.statement.child[match[1].node.statement.child.type]}))`;
rangeElement.appendChild(rangeType);
const rangeValue = document.createElement('span');
rangeValue.className = 'feature-value';
const minima = match[1].node.statement.min
const maxima = match[1].node.statement.max
if (minima == maxima) {
rangeValue.textContent = `${minima}`;
rangeType.textContent = `count(${match[1].node.statement.child.type})`;
} else {
rangeValue.textContent = `${minima} or more`;
}
rangeElement.appendChild(rangeValue);
const rangeLocation = document.createElement('span');
rangeLocation.className = 'feature-location';
if (match[1].locations[0].value) {
const locations = match[1].locations.map(loc => '0x' + loc.value.toString(16).toUpperCase());
rangeLocation.textContent = locations.join(', ');
}
rangeElement.appendChild(rangeLocation);
return rangeElement;
}
// If the match is a feature, render its type and value
if (match[1].node.type === 'feature') {
const featureType = document.createElement('span');
featureType.className = 'feature-type';
featureType.textContent = match[1].node.feature.type;
matchElement.appendChild(featureType);
const featureValue = document.createElement('span');
featureValue.className = 'feature-value';
featureValue.textContent = match[1].node.feature[match[1].node.feature.type];
matchElement.appendChild(featureValue);
const featureLocation = document.createElement('span');
featureLocation.className = 'feature-location';
if (match[1].locations[0].value) {
const locations = match[1].locations.map(loc => '0x' + loc.value.toString(16).toUpperCase());
featureLocation.textContent = locations.join(', ');
}
matchElement.appendChild(featureLocation);
} else {
// Check if the match is an optional statement - these always have `success: true`.
const isOptional = match[1].node.statement.type === 'optional';
// If it's an optional statement, check if any of its children have success set to true
if (isOptional) {
const hasSuccessfulChild = match[1].children.some(child => child.success);
// If none of the children have success set to true, don't render the optional statement
if (!hasSuccessfulChild) {
return null;
}
}
// Create a details element for the match
const matchDetails = document.createElement('details');
// Create a summary element for the match
const matchSummary = document.createElement('summary');
// Create a div for the summary content
const matchSummaryContent = document.createElement('div');
matchSummaryContent.className = 'summary-content';
// Add the statement type to the summary content
const statementType = document.createElement('span');
statementType.className = 'statement-type';
statementType.textContent = match[1].node.statement.type;
matchSummaryContent.appendChild(statementType);
// Append the summary content to the summary element
matchSummary.appendChild(matchSummaryContent);
// Append the summary element to the details element
matchDetails.appendChild(matchSummary);
// Create a list for the match's children
const childrenList = document.createElement('ul');
// Iterate over each child in the match
for (const child of match[1].children) {
// Recursively render the child if it is successful
if (child.success) {
const childElement = renderMatch([null, child]);
if (childElement !== null) {
childrenList.appendChild(childElement);
}
}
}
// Append the children list to the details element
matchDetails.appendChild(childrenList);
// Append the match details to the match list item
matchElement.appendChild(matchDetails);
}
return matchElement;
}
/**
* Determines the statement type based on the feature object.
* @param {Object} feature - The feature object.
* @returns {string} The statement type.
*/
function getStatementType(feature) {
if (feature.type === 'and' || feature.type === 'or' || feature.type === 'not') {
return feature.type;
} else if (feature.match) {
return 'match';
} else {
return 'feature';
}
}
/**
* Checks if the given feature is a leaf feature (i.e., not a compound feature).
* @param {Object} feature - The feature object.
* @returns {boolean} True if the feature is a leaf feature, false otherwise.
*/
function isLeafFeature(feature) {
return feature.type !== 'and' && feature.type !== 'or' && feature.type !== 'not' && !feature.match;
}
/**
* Adds a metadata table to the document body.
* @param {Object} metadata - The metadata object containing sample and analysis information.
*/
function addMetadataTable(metadata) {
const metadataContainer = document.createElement("div");
metadataContainer.className = "metadata-container";
const table = document.createElement("table");
table.className = "metadata-table";
const rows = [{
key: "MD5",
value: metadata.sample.md5
},
{
key: "SHA1",
value: metadata.sample.sha1
},
{
key: "SHA256",
value: metadata.sample.sha256
},
{
key: "Extractor",
value: metadata.analysis.extractor
},
{
key: "Analysis",
value: metadata.flavor
},
{
key: "OS",
value: metadata.analysis.os
},
{
key: "Format",
value: metadata.analysis.format
},
{
key: "Arch",
value: metadata.analysis.arch
},
{
key: "Path",
value: metadata.sample.path
},
{
key: "Base Address",
value: "0x" + metadata.analysis.base_address.value.toString(16)
},
{
key: "Version",
value: metadata.version
},
{
key: "Timestamp",
value: metadata.timestamp
},
{
key: "Function Count",
value: Object.keys(metadata.analysis.feature_counts).length
}
];
rows.forEach((row) => {
const tr = document.createElement("tr");
const keyTd = document.createElement("td");
keyTd.textContent = row.key;
tr.appendChild(keyTd);
const valueTd = document.createElement("td");
valueTd.textContent = row.value;
tr.appendChild(valueTd);
table.appendChild(tr);
});
metadataContainer.appendChild(table);
document.body.insertBefore(metadataContainer, document.querySelector("h1"));
}
// TODO(s-ff): introduce a "Upload from local" and "Load from URL" options
/* For now we are using a static al-khaser_64.exe rdoc for testing */
let url = 'https://raw.githubusercontent.com/mandiant/capa-testfiles/master/rd/al-khaser_x64.exe_.json';
fetch(url)
.then(res => res.json())
.then(result_document => {
addMetadataTable(result_document.meta);
renderJSON(result_document);
})
.catch(err => {
throw err
});