move webui to web/explorer

This commit is contained in:
Soufiane Fariss
2024-08-05 15:17:04 +02:00
parent 6b19e7b372
commit 7e64306f1c
36 changed files with 2728 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-prettier/skip-formatting'],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'vue/multi-word-component-names': 'off'
}
}

28
web/explorer/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.vscode
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 4,
"singleQuote": false,
"printWidth": 120,
"trailingComma": "none"
}

41
web/explorer/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Capa Explorer WebUI
Capa Explorer WebUI is a web-based user interface for exploring program capabilities identified by the capa tool. It provides an intuitive and interactive way to analyze and visualize the results of capa analysis.
## Features
- **Import capa Results**: Easily upload or import capa JSON result files.
- **Interactive Tree View**: Explore rule matches in a hierarchical structure.
- **Function Capabilities**: Group capabilities by function for static analysis.
- **Process Capabilities**: Group capabilities by process for dynamic analysis.
- **Toggeable Settings**: Toggle between different view modes and filter options.
## Getting Started
1. **Access the Application**: Open the Capa Explorer WebUI in your web browser.
2. **Import capa Results**:
- Click on "Upload from local" to select a capa JSON file from your computer (with a version higher than 7.0.0).
- Or, paste a URL to a capa JSON file and click the arrow button to load it.
- Alternatively, use the "Preview Static" or "Preview Dynamic" for sample data.
3. **Explore the Results**:
- Use the tree view to navigate through the identified capabilities.
- Toggle between different views using the checkboxes in the settings panel:
- "Show capabilities by function/process" for grouped analysis.
- "Show library rule matches" to include or exclude library rules.
4. **Interact with the Data**:
- Expand/collapse nodes in the TreeTable to see more details.
- Use the search and filter options to find specific features or capabilities (rules).
- Right click on rule names to view their source code or additional information.
## Feedback and Contributions
We welcome your feedback and contributions to improve the web-based Capa Explorer. Please report any issues or suggest enhancements through the `capa` GitHub repository.
---
For developers interested in building or contributing to Capa Explorer WebUI, please refer to our [Development Guide](CONTRIBUTION.md).

13
web/explorer/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capa Explorer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

39
web/explorer/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "capa-webui",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:bundle": "vite build --mode bundle",
"preview": "vite preview",
"test": "vitest",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@highlightjs/vue-plugin": "^2.1.0",
"@primevue/themes": "^4.0.0-rc.2",
"pako": "^2.1.0",
"plotly.js-dist": "^2.34.0",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.1.0",
"prettier": "^3.2.5",
"vite": "^5.3.1",
"vite-plugin-singlefile": "^2.0.2",
"vitest": "^1.6.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

15
web/explorer/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<header>
<div class="wrapper">
<BannerHeader />
<NavBar />
</div>
</header>
<RouterView />
</template>
<script setup>
import { RouterView } from "vue-router";
import NavBar from "./components/NavBar.vue";
import BannerHeader from "./components/BannerHeader.vue";
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,20 @@
body {
margin: 0 auto;
font-weight: normal;
font-family: Arial, Helvetica, sans-serif;
}
a {
text-decoration: none;
color: inherit;
transition: color 0.15s ease-in-out;
}
a:hover {
color: var(--primary-color);
}
/* remove the border from rows other than rule names */
.p-treetable-tbody > tr:not(:is([aria-level="1"])) > td {
border: none !important;
}

View File

@@ -0,0 +1,45 @@
<template>
<div
v-if="showBanner"
class="bg-bluegray-900 text-gray-100 flex justify-content-between lg:justify-content-center align-items-center flex-wrap"
>
<div class="font-bold mr-8">This is an early release</div>
<div class="align-items-center hidden lg:flex">
<span class="line-height-3">Please report any bugs, enhancements or features in the </span>
<a
v-ripple
href="https://github.com/mandiant/capa/issues"
class="flex align-items-center ml-2 mr-8 text-white"
>
<span class="no-underline font-bold">Github issues</span>
<i class="pi pi-github ml-2"></i>
</a>
</div>
<a
v-ripple
@click="closeBanner"
class="flex align-items-center no-underline justify-content-center border-circle text-gray-50 hover:bg-bluegray-700 cursor-pointer transition-colors transition-duration-150"
style="width: 2rem; height: 2rem"
>
<i class="pi pi-times"></i>
</a>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
const showBanner = ref(true);
onMounted(() => {
const bannerHidden = localStorage.getItem("bannerHidden");
if (bannerHidden === "true") {
showBanner.value = false;
}
});
const closeBanner = () => {
showBanner.value = false;
localStorage.setItem("bannerHidden", "true");
};
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-column align-items-center">
<div class="text-center">
<h1>
<img src="../assets/images/logo-full.png" alt="Capa: identify program capabilities" />
<h6 class="font-medium" style="color: rgb(176, 26, 26)">capa: identify program capabilities</h6>
</h1>
</div>
<div>
<p class="text-xl max-w-75rem" style="max-width: 75ch">
Capa-WebUI is a web-based tool for exploring the capabilities identified in a program. It can be used to
search and display the rule matches in different viewing modes.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<template>
<div>
<DataTable
:value="tableData"
rowGroupMode="rowspan"
groupRowsBy="funcaddr"
sortMode="single"
removableSort
size="small"
:filters="filters"
:rowHover="true"
:filterMode="filterMode"
filterDisplay="menu"
:globalFilterFields="['funcaddr', 'ruleName', 'namespace']"
>
<template #header>
<InputText v-model="filters['global'].value" placeholder="Global Search" />
</template>
<Column field="funcaddr" sortable header="Function Address" :rowspan="3" class="w-min">
<template #body="slotProps">
<span style="font-family: monospace">{{ slotProps.data.funcaddr }}</span>
<span v-if="slotProps.data.matchCount > 1" class="font-italic">
({{ slotProps.data.matchCount }} matches)
</span>
</template>
</Column>
<Column field="ruleName" header="Matches" class="w-min">
<template #body="slotProps">
{{ slotProps.data.ruleName }}
<LibraryTag v-if="slotProps.data.lib" />
</template>
</Column>
<Column field="namespace" header="Namespace"></Column>
</DataTable>
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
<highlightjs lang="yml" :code="currentSource" class="bg-white" />
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Dialog from "primevue/dialog";
import LibraryTag from "./misc/LibraryTag.vue";
import InputText from "primevue/inputtext";
const props = defineProps({
data: {
type: Object,
required: true
},
showLibraryRules: {
type: Boolean,
default: false
}
});
const filters = ref({
global: { value: null, matchMode: "contains" }
});
const filterMode = ref("lenient");
const sourceDialogVisible = ref(false);
const currentSource = ref("");
import { parseFunctionCapabilities } from "../utils/rdocParser";
const tableData = computed(() => parseFunctionCapabilities(props.data, props.showLibraryRules));
</script>
<style scoped>
/* tighten up the spacing between rows */
:deep(.p-datatable.p-datatable-sm .p-datatable-tbody > tr > td) {
padding: 0.1rem 0.5rem !important;
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<!-- Main container with gradient background -->
<div
class="flex flex-column sm:flex-row align-items-stretch sm:align-items-center justify-content-between w-full p-3 shadow-1"
:style="{ background: 'linear-gradient(to right, #2c3e50, #3498db)' }"
>
<!-- File information section -->
<div class="flex-grow-1 mr-3 mb-3 sm:mb-0">
<h1 class="text-xl m-0 text-overflow-ellipsis overflow-hidden white-space-nowrap text-white">
{{ fileName }}
</h1>
<p class="text-xs mt-1 mb-0 text-white-alpha-70">
SHA256:
<a
:href="`https://www.virustotal.com/gui/file/${sha256}`"
target="_blank"
class="text-white-alpha-90 hover:text-white"
>{{ sha256 }}</a
>
</p>
</div>
<!-- Analysis information section -->
<div class="flex-grow-1 mr-3 mb-3 sm:mb-0">
<!-- OS Program Format Arch -->
<div class="flex flex-wrap align-items-center text-sm m-0 line-height-3 text-white">
<span class="capitalize mr-2">{{ data.meta.analysis.os }}</span>
<span class="sm:inline-block mx-2 text-white-alpha-30"></span>
<span class="uppercase mr-2">{{ data.meta.analysis.format }}</span>
<span class="sm:inline-block mx-2 text-white-alpha-30"></span>
<span class="uppercase">{{ data.meta.analysis.arch }}</span>
</div>
<!-- Flavor Extractor CAPA Version Timestamp -->
<div class="flex flex-wrap align-items-center text-sm m-0 line-height-3 text-white">
<span class="capitalize mr-1">{{ flavor }}</span>
<span class="mr-1">analysis using</span>
<span class="mr-2">{{ data.meta.analysis.extractor.split(/(Feature)?Extractor/)[0] }}</span>
<span class="sm:inline-block mx-2 text-white-alpha-30"></span>
<span class="mr-2">CAPA v{{ data.meta.version }}</span>
<span class="sm:inline-block mx-2 text-white-alpha-30"></span>
<span>{{ new Date(data.meta.timestamp).toLocaleString() }}</span>
</div>
</div>
<!-- Key metrics section -->
<div class="flex justify-content-around sm:justify-content-between flex-grow-1">
<!-- Rules count -->
<div class="text-center mr-3 sm:mr-0">
<span class="block text-xl font-bold text-white">{{ keyMetrics.ruleCount }}</span>
<span class="block text-xs uppercase text-white-alpha-70">Rules</span>
</div>
<!-- Namespaces count -->
<div class="text-center mr-3 sm:mr-0">
<span class="block text-xl font-bold text-white">{{ keyMetrics.namespaceCount }}</span>
<span class="block text-xs uppercase text-white-alpha-70">Namespaces</span>
</div>
<!-- Functions or Processes count -->
<div class="text-center">
<span class="block text-xl font-bold text-white">{{ keyMetrics.functionOrProcessCount }}</span>
<span class="block text-xs uppercase text-white-alpha-70">
{{ flavor === "static" ? "Functions" : "Processes" }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
const props = defineProps({
data: {
type: Object,
required: true
}
});
const keyMetrics = ref({
ruleCount: 0,
namespaceCount: 0,
functionOrProcessCount: 0
});
// get the filename from the path, e.g. "malware.exe" from "/home/user/malware.exe"
const fileName = props.data.meta.sample.path.split("/").pop();
// get the flavor from the metadata, e.g. "dynamic" or "static"
const flavor = props.data.meta.flavor;
// get the SHA256 hash from the metadata
const sha256 = props.data.meta.sample.sha256.toUpperCase();
// Function to parse metadata and update key metrics
const parseMetadata = () => {
if (props.data) {
keyMetrics.value = {
ruleCount: Object.keys(props.data.rules).length,
namespaceCount: new Set(Object.values(props.data.rules).map((rule) => rule.meta.namespace)).size,
functionOrProcessCount:
flavor === "static"
? props.data.meta.analysis.feature_counts.functions.length
: props.data.meta.analysis.feature_counts.processes.length
};
}
};
// Call parseMetadata when the component is mounted
onMounted(() => {
parseMetadata();
});
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div ref="chartRef" class="w-screen h-screen"></div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import Plotly from "plotly.js-dist";
const props = defineProps({
data: {
type: Object,
required: true
}
});
const chartRef = ref(null);
const createSunburstData = (rules) => {
const data = {
ids: [],
labels: [],
parents: [],
values: []
};
const addNamespace = (namespace, value) => {
const parts = namespace.split("/");
let currentId = "";
let parent = "";
parts.forEach((part) => {
currentId = currentId ? `${currentId}/${part}` : part;
if (!data.ids.includes(currentId)) {
data.ids.push(currentId);
data.labels.push(part);
data.parents.push(parent);
data.values.push(0);
}
const valueIndex = data.ids.indexOf(currentId);
data.values[valueIndex] += value;
parent = currentId;
});
return parent;
};
Object.entries(rules).forEach(([ruleName, rule]) => {
if (rule.meta.lib) return; // Skip library rules
const namespace = rule.meta.namespace || "root";
const parent = addNamespace(namespace, rule.matches.length);
// Add the rule itself
data.ids.push(ruleName);
data.labels.push(rule.meta.name);
data.parents.push(parent);
data.values.push(rule.matches.length);
});
return data;
};
const renderChart = () => {
if (!chartRef.value) return;
const sunburstData = createSunburstData(props.data.rules);
const layout = {
margin: { l: 0, r: 0, b: 0, t: 0 },
sunburstcolorway: [
"#636efa",
"#EF553B",
"#00cc96",
"#ab63fa",
"#19d3f3",
"#e763fa",
"#FECB52",
"#FFA15A",
"#FF6692",
"#B6E880"
],
extendsunburstcolorway: true
};
const config = {
responsive: true
};
Plotly.newPlot(
chartRef.value,
[
{
type: "sunburst",
ids: sunburstData.ids,
labels: sunburstData.labels,
parents: sunburstData.parents,
values: sunburstData.values,
outsidetextfont: { size: 20, color: "#377eb8" },
leaf: { opacity: 0.6 },
marker: { line: { width: 2 } },
branchvalues: "total"
}
],
layout,
config
);
return sunburstData;
};
onMounted(() => {
const data = renderChart();
console.log(data);
});
watch(
() => props.data,
() => {
renderChart();
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,31 @@
<script setup>
import { ref } from "vue";
import Menubar from "primevue/menubar";
const items = ref([
{
label: "Import Analysis",
icon: "pi pi-file-import",
// TODO(s-ff): This is not the conventinal way of navigating to a new page.
command: () => window.location.replace(window.location.origin + "/capa/") // reload the page
}
]);
</script>
<template>
<Menubar :model="items" class="p-1">
<template #end>
<div class="flex align-items-center gap-3">
<a
v-ripple
href="https://github.com/mandiant/capa"
class="flex align-items-center justify-content-center text-color w-2rem"
>
<i id="github-icon" class="pi pi-github text-2xl"></i>
</a>
<img src="../assets/images/icon.png" alt="Logo" class="w-2rem" />
</div>
</template>
</Menubar>
</template>

View File

@@ -0,0 +1,223 @@
<template>
<div class="card">
<TreeTable
:value="processTree"
v-model:expandedKeys="expandedKeys"
:filters="filters"
filterMode="lenient"
sortField="pid"
:sortOrder="1"
rowHover="true"
>
<Column field="processname" header="Process" expander>
<template #body="slotProps">
<span
:id="'process-' + slotProps.node.key"
class="cursor-pointer flex align-items-center"
@mouseenter="showTooltip($event, slotProps.node)"
@mouseleave="hideTooltip"
>
<span
class="text-lg text-overflow-ellipsis overflow-hidden white-space-nowrap inline-block max-w-20rem"
style="font-family: monospace"
>
{{ slotProps.node.data.processname }}
</span>
<span class="ml-2"> - PID: {{ slotProps.node.data.pid }} </span>
<span v-if="slotProps.node.data.uniqueMatchCount > 0" class="font-italic ml-2">
({{ slotProps.node.data.uniqueMatchCount }} unique
{{ slotProps.node.data.uniqueMatchCount > 1 ? "matches" : "match" }})
</span>
</span>
</template>
</Column>
<Column field="pid" header="PID" sortable>
<template #body="slotProps">
<span :style="{ color: getColorForId(slotProps.node.data.pid) }">
{{ slotProps.node.data.pid }}
</span>
</template>
</Column>
<Column field="ppid" header="PPID" sortable>
<template #body="slotProps">
<span :style="{ color: getColorForId(slotProps.node.data.ppid) }">
{{ slotProps.node.data.ppid }}
</span>
</template>
</Column>
</TreeTable>
<div
v-if="tooltipVisible"
class="fixed bg-gray-800 text-white p-3 border-round-sm z-5 max-w-50rem shadow-2"
:style="tooltipStyle"
>
<div v-for="rule in currentNode.data.uniqueRules" :key="rule.name">
{{ rule.name }}
<span class="font-italic"
>({{ rule.matchCount }} {{ rule.scope }} {{ rule.matchCount > 1 ? "matches" : "match" }})</span
>
<LibraryTag v-if="rule.lib" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import TreeTable from "primevue/treetable";
import Column from "primevue/column";
import LibraryTag from "./misc/LibraryTag.vue";
const props = defineProps({
data: {
type: Object,
required: true
},
showLibraryRules: {
type: Boolean,
default: false
}
});
const filters = ref({});
const expandedKeys = ref({});
const tooltipVisible = ref(false);
const currentNode = ref(null);
const tooltipStyle = ref({
position: "fixed",
top: "0px",
left: "0px"
});
const getProcessIds = (location) => {
if (!location || location.type === "no address") {
return null;
}
if (Array.isArray(location.value) && location.value.length >= 2) {
return {
ppid: location.value[0],
pid: location.value[1]
};
}
return null;
};
const processTree = computed(() => {
if (
!props.data ||
!props.data.meta ||
!props.data.meta.analysis ||
!props.data.meta.analysis.layout ||
!props.data.meta.analysis.layout.processes
) {
console.error("Invalid data structure");
return [];
}
const processes = props.data.meta.analysis.layout.processes;
const rules = props.data.rules || {};
const processMap = new Map();
// create all process nodes
processes.forEach((process) => {
if (!process.address || !Array.isArray(process.address.value) || process.address.value.length < 2) {
console.warn("Invalid process structure", process);
return;
}
const [ppid, pid] = process.address.value;
processMap.set(pid, {
key: `process-${pid}`,
data: {
processname: process.name || "<Unknown Process>",
pid,
ppid,
uniqueMatchCount: 0,
uniqueRules: new Map()
},
children: []
});
});
// build the tree structure and add rule matches
Object.entries(rules).forEach(([ruleName, rule]) => {
if (!props.showLibraryRules && rule.meta && rule.meta.lib) return;
if (!rule.matches || !Array.isArray(rule.matches)) return;
rule.matches.forEach((match) => {
if (!Array.isArray(match) || match.length === 0) return;
const [location] = match;
const ids = getProcessIds(location);
if (ids && processMap.has(ids.pid)) {
const processNode = processMap.get(ids.pid);
if (!processNode.data.uniqueRules.has(ruleName)) {
processNode.data.uniqueMatchCount++;
processNode.data.uniqueRules.set(ruleName, {
name: ruleName,
lib: rule.meta && rule.meta.lib,
matchCount: 0,
scope: location.type
});
}
processNode.data.uniqueRules.get(ruleName).matchCount++;
}
});
});
// build the final tree structure
const rootProcesses = [];
processMap.forEach((processNode) => {
console.log(processNode);
processNode.data.uniqueRules = Array.from(processNode.data.uniqueRules.values());
const parentProcess = processMap.get(processNode.data.ppid);
if (parentProcess) {
parentProcess.children.push(processNode);
} else {
rootProcesses.push(processNode);
}
});
return rootProcesses;
});
const getColorForId = (id) => {
if (id === undefined || id === null) return "black";
const hue = Math.abs((id * 41) % 360);
return `hsl(${hue}, 70%, 40%)`;
};
const showTooltip = (event, node) => {
if (node.data.uniqueMatchCount > 0) {
currentNode.value = node;
tooltipVisible.value = true;
updateTooltipPosition(event);
}
};
const hideTooltip = () => {
tooltipVisible.value = false;
currentNode.value = null;
};
const updateTooltipPosition = (event) => {
const offset = 10;
tooltipStyle.value = {
position: "fixed",
top: `${event.clientY + offset}px`,
left: `${event.clientX + offset}px`
};
};
const handleMouseMove = (event) => {
if (tooltipVisible.value) {
updateTooltipPosition(event);
}
};
onMounted(() => {
document.addEventListener("mousemove", handleMouseMove);
});
onUnmounted(() => {
document.removeEventListener("mousemove", handleMouseMove);
});
</script>

View File

@@ -0,0 +1,289 @@
<template>
<div class="card">
<TreeTable
:value="filteredTreeData"
v-model:expandedKeys="expandedKeys"
size="small"
:filters="filters"
:filterMode="filterMode"
sortField="namespace"
:sortOrder="-1"
removableSort
:rowHover="true"
:indentation="1.3"
selectionMode="single"
@node-select="onNodeSelect"
:pt="{
row: ({ instance }) => ({
oncontextmenu: (event) => onRightClick(event, instance)
})
}"
>
<template #header>
<div class="flex justify-content-end w-full">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters['global']" placeholder="Global search" />
</IconField>
</div>
</template>
<!-- Rule column (always visible) -->
<Column field="name" header="Rule" :sortable="true" :expander="true" filterMatchMode="contains">
<template #filter>
<InputText v-model="filters['name']" type="text" placeholder="Filter by Rule or Feature" />
</template>
<template #body="{ node }">
<RuleColumn :node="node" />
</template>
</Column>
<Column
v-for="col in visibleColumns"
:key="col.field"
:field="col.field"
:header="props.data.meta.flavor === 'dynamic' && col.field === 'address' ? 'Process' : col.header"
:sortable="col.field !== 'source'"
:class="{ 'w-3': col.field === 'mbc', 'w-full': col.field === 'name' }"
filterMatchMode="contains"
>
<template #filter>
<InputText v-model="filters[col.field]" type="text" :placeholder="`Filter by ${col.header}`" />
</template>
<template #body="slotProps">
<!-- Address column -->
<span v-if="col.field === 'address'" class="text-sm" style="font-family: monospace">
{{ slotProps.node.data.address }}
</span>
<!-- Tactic column -->
<div v-else-if="col.field === 'tactic' && slotProps.node.data.attack">
<div v-for="(attack, index) in slotProps.node.data.attack" :key="index">
<a :href="createATTACKHref(attack)" target="_blank">
{{ attack.technique }}
<span class="text-500 text-sm font-normal ml-1">({{ attack.id }})</span>
</a>
<div
v-for="(technique, techIndex) in attack.techniques"
:key="techIndex"
style="font-size: 0.8em; margin-left: 1em"
>
<a :href="createATTACKHref(technique)" target="_blank">
{{ technique.technique }}
<span class="text-500 text-xs font-normal ml-1">({{ technique.id }})</span>
</a>
</div>
</div>
</div>
<!-- MBC column -->
<div v-else-if="col.field === 'mbc' && slotProps.node.data.mbc">
<div v-for="(mbc, index) in slotProps.node.data.mbc" :key="index">
<a :href="createMBCHref(mbc)" target="_blank">
{{ mbc.parts.join("::") }}
<span class="text-500 text-sm font-normal opacity-80 ml-1">[{{ mbc.id }}]</span>
</a>
</div>
</div>
<!-- Namespace column -->
<span v-else-if="col.field === 'namespace' && !slotProps.node.data.lib">
{{ slotProps.node.data.namespace }}
</span>
</template>
</Column>
</TreeTable>
<ContextMenu ref="menu" :model="contextMenuItems">
<template #item="{ item, props }">
<a v-ripple v-bind="props.action" :href="item.url" :target="item.target">
<span v-if="item.icon !== 'vt-icon'" :class="item.icon" />
<VTIcon v-else-if="item.icon === 'vt-icon'" />
<span>{{ item.label }}</span>
</a>
</template>
</ContextMenu>
<Toast />
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
<highlightjs autodetect :code="currentSource" />
</Dialog>
</div>
</template>
<script setup>
import "highlight.js/styles/stackoverflow-light.css";
import { ref, onMounted, computed } from "vue";
import TreeTable from "primevue/treetable";
import InputText from "primevue/inputtext";
import Dialog from "primevue/dialog";
import Column from "primevue/column";
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";
import ContextMenu from "primevue/contextmenu";
import RuleColumn from "./columns/RuleColumn.vue";
import VTIcon from "./misc/VTIcon.vue";
import { parseRules } from "../utils/rdocParser";
import { createMBCHref, createATTACKHref } from "../utils/urlHelpers";
const props = defineProps({
data: {
type: Object,
required: true
},
showLibraryRules: {
type: Boolean,
default: false
}
});
const treeData = ref([]);
const filters = ref({});
const filterMode = ref("lenient");
const sourceDialogVisible = ref(false);
const currentSource = ref("");
const expandedKeys = ref({});
const menu = ref();
const selectedNode = ref({});
const contextMenuItems = computed(() => [
{
label: "View source",
icon: "pi pi-eye",
command: () => {
showSource(selectedNode.value.data.source);
}
},
{
label: "View rule in capa-rules",
icon: "pi pi-external-link",
target: "_blank",
url: selectedNode.value.url
},
{
label: "Lookup rule in VirusTotal",
icon: "vt-icon",
target: "_blank",
url: selectedNode.value.vturl
}
]);
const onRightClick = (event, instance) => {
if (instance.node.data.source) {
selectedNode.value = instance.node;
// contrust capa-rules url
selectedNode.value.url = `https://github.com/mandiant/capa-rules/blob/master/${instance.node.data.namespace || "lib"}/${instance.node.data.name.toLowerCase().replace(/\s+/g, "-")}.yml`;
// construct VirusTotal deep link
const behaviourSignature = `behaviour_signature:"${instance.node.data.name}"`;
selectedNode.value.vturl = `https://www.virustotal.com/gui/search/${encodeURIComponent(behaviourSignature)}/files`;
menu.value.show(event);
}
};
/*
* Expand node on click
*/
const onNodeSelect = (node) => {
const nodeKey = node.key;
const nodeType = node.data.type;
// We only expand rule and match locations, if not return
if (nodeType !== "rule" && nodeType !== "match location") return;
// If the node is already expanded, collapse it
if (expandedKeys.value[nodeKey]) {
delete expandedKeys.value[nodeKey];
return;
}
if (nodeType === "rule") {
// For rule nodes, clear existing expanded keys and set the clicked rule as expanded
// and expand the first (child) match by default
expandedKeys.value = { [nodeKey]: true, [`${nodeKey}-0`]: true };
} else if (nodeType === "match location") {
// For match location nodes, we need to keep the parent expanded
// and toggle the clicked node while collapsing siblings
const [parentKey] = nodeKey.split("-");
expandedKeys.value = { [parentKey]: true, [`${nodeKey}`]: true };
} else {
return;
}
};
// All available columns
const visibleColumns = ref([
{ field: "address", header: "Address" },
{ field: "namespace", header: "Namespace" },
{ field: "tactic", header: "ATT&CK Tactic" },
{ field: "mbc", header: "Malware Behaviour Catalogue" }
]);
// Filter out the treeData for showing/hiding lib rules
const filteredTreeData = computed(() => {
if (props.showLibraryRules) {
return treeData.value; // Return all data when showLibraryRules is true
} else {
// Filter out library rules when showLibraryRules is false
const filterNode = (node) => {
if (node.data && node.data.lib) {
return false;
}
if (node.children) {
node.children = node.children.filter(filterNode);
}
return true;
};
return treeData.value.filter(filterNode);
}
});
const showSource = (source) => {
currentSource.value = source;
sourceDialogVisible.value = true;
};
onMounted(() => {
if (props.data && props.data.rules) {
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout);
} else {
console.error("Invalid data prop:", props.data);
}
});
</script>
<style scoped>
/* Disable the toggle button for statement and features */
:deep(
.p-treetable-tbody
> tr:not(:is([aria-level="1"], [aria-level="2"]))
> td
> div
> .p-treetable-node-toggle-button
) {
visibility: hidden !important;
height: 1.3rem;
}
/* Disable the toggle button for rules */
:deep(.p-treetable-tbody > tr:is([aria-level="1"]) > td > div > .p-treetable-node-toggle-button) {
visibility: collapse !important;
height: 1.3rem;
}
/* Make all matches nodes (i.e. not rule names) slightly smaller,
and tighten up the spacing between the rows */
:deep(.p-treetable-tbody > tr:not([aria-level="1"]) > td) {
font-size: 0.95rem;
padding: 0rem 0.5rem !important;
}
/* Optional: Add a subtle background to root-level rows for better distinction */
:deep(.p-treetable-tbody > tr[aria-level="1"]) {
background-color: #f9f9f9;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<Card>
<template #content>
<div class="flex align-items-center flex-row gap-3">
<div class="flex flex-row align-items-center gap-2">
<Checkbox
v-model="showCapabilitiesByFunctionOrProcess"
inputId="showCapabilitiesByFunctionOrProcess"
:binary="true"
:disabled="showNamespaceChart"
/>
<label for="showCapabilitiesByFunctionOrProcess">{{ capabilitiesLabel }}</label>
</div>
<div class="flex flex-row align-items-center gap-2">
<Checkbox
v-model="showLibraryRules"
inputId="showLibraryRules"
:binary="true"
:disabled="showNamespaceChart"
/>
<label for="showLibraryRules">
<span v-if="libraryRuleMatchesCount > 1">
Show {{ libraryRuleMatchesCount }} library rule matches
</span>
<span v-else>Show 1 library rule match</span>
</label>
</div>
<div class="flex flex-row align-items-center gap-2">
<Checkbox v-model="showNamespaceChart" inputId="showNamespaceChart" :binary="true" />
<label for="showNamespaceChart"> Show namespace chart </label>
</div>
</div>
</template>
</Card>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import Checkbox from "primevue/checkbox";
const props = defineProps({
flavor: {
type: String,
required: true
},
libraryRuleMatchesCount: {
type: Number,
required: true
}
});
const showCapabilitiesByFunctionOrProcess = ref(false);
const showLibraryRules = ref(false);
const showNamespaceChart = ref(false);
const emit = defineEmits([
"update:show-capabilities-by-function-or-process",
"update:show-library-rules",
"update:show-namespace-chart"
]);
const capabilitiesLabel = computed(() => {
return props.flavor === "static" ? "Show capabilities by function" : "Show capabilities by process";
});
watch(showCapabilitiesByFunctionOrProcess, (newValue) => {
emit("update:show-capabilities-by-function-or-process", newValue);
});
watch(showLibraryRules, (newValue) => {
emit("update:show-library-rules", newValue);
});
watch(showNamespaceChart, (newValue) => {
emit("update:show-namespace-chart", newValue);
});
</script>

View File

@@ -0,0 +1,91 @@
<template>
<Card>
<template #content>
<div class="flex flex-wrap align-items-center justify-content-center gap-3">
<div class="flex-grow-1 flex align-items-center justify-content-center">
<FileUpload
mode="basic"
name="model[]"
accept=".json,.gz"
:max-file-size="10000000"
:auto="true"
:custom-upload="true"
choose-label="Upload from local"
@uploader="$emit('load-from-local', $event)"
/>
</div>
<Divider layout="vertical" class="hidden-mobile">
<b>OR</b>
</Divider>
<Divider layout="horizontal" class="visible-mobile" align="center">
<b>OR</b>
</Divider>
<div class="flex-grow-1 flex align-items-center justify-content-center gap-2">
<FloatLabel>
<InputText id="url" type="text" v-model="loadURL" />
<label for="url">Load from URL</label>
</FloatLabel>
<Button icon="pi pi-arrow-right" @click="$emit('load-from-url', loadURL)" :disabled="!loadURL" />
</div>
<Divider layout="vertical" class="hidden-mobile">
<b>OR</b>
</Divider>
<Divider layout="horizontal" class="visible-mobile" align="center">
<b>OR</b>
</Divider>
<div class="flex-grow-1 flex align-items-center justify-content-center">
<Button label="Preview Static" @click="$emit('load-demo-static')" class="p-button" />
</div>
<Divider layout="vertical" class="hidden-mobile">
<b>OR</b>
</Divider>
<Divider layout="horizontal" class="visible-mobile" align="center">
<b>OR</b>
</Divider>
<div class="flex-grow-1 flex align-items-center justify-content-center">
<Button label="Preview Dynamic" @click="$emit('load-demo-dynamic')" class="p-button" />
</div>
</div>
</template>
</Card>
</template>
<script setup>
import { ref } from "vue";
import Card from "primevue/card";
import FileUpload from "primevue/fileupload";
import Divider from "primevue/divider";
import FloatLabel from "primevue/floatlabel";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
const loadURL = ref("");
defineEmits(["load-from-local", "load-from-url", "load-demo-static", "load-demo-dynamic"]);
</script>
<style scoped>
@media screen and (min-width: 769px) {
.hidden-mobile {
display: flex !important;
}
.visible-mobile {
display: none !important;
}
}
@media screen and (max-width: 768px) {
.hidden-mobile {
display: none !important;
}
.visible-mobile {
display: flex !important;
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div>
<template v-if="node.data.type === 'rule'">
{{ node.data.name }}
</template>
<template v-else-if="node.data.type === 'match location'">
<span class="text-sm font-italic">{{ node.data.name }}</span>
</template>
<template v-else-if="node.data.type === 'statement'"
>-
<span
:class="{
'text-green-700': node.data.typeValue === 'range',
'font-semibold': node.data.typeValue !== 'range'
}"
>
{{ node.data.name }}
</span>
</template>
<template v-else-if="node.data.type === 'feature'">
<span
>- {{ node.data.typeValue }}:
<span :class="{ 'text-green-700': node.data.typeValue !== 'regex' }" style="font-family: monospace">{{
node.data.name
}}</span></span
>
</template>
<template v-else-if="node.data.type === 'regex-capture'">
- <span class="text-green-700" style="font-family: monospace">{{ node.data.name }}</span>
</template>
<template v-else-if="node.data.type === 'call-info'">
<highlightjs lang="c" :code="node.data.name.callInfo" style="background-color: #f0f0f0" />
</template>
<span v-if="node.data.description" class="text-gray-500 text-sm" style="font-size: 90%">
= {{ node.data.description }}
</span>
<span v-if="node.data.matchCount > 1" class="font-italic"> ({{ node.data.matchCount }} matches) </span>
<LibraryTag v-if="node.data.lib && node.data.matchCount" />
</div>
</template>
<script setup>
import { defineProps } from "vue";
import LibraryTag from "../misc/LibraryTag.vue";
defineProps({
node: {
type: Object,
required: true
}
});
</script>

View File

@@ -0,0 +1,13 @@
<template>
<Tag
class="ml-2"
style="scale: 0.8"
value="lib"
severity="info"
v-tooltip.right="'Library rules capture common logic'"
/>
</template>
<script setup>
import Tag from "primevue/tag";
</script>

View File

@@ -0,0 +1,5 @@
<template>
<svg width="14" height="14" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10.87 12L0 22.68h24V1.32H0zm10.73 8.52H5.28l8.637-8.448L5.28 3.48H21.6z" fill="currentColor" />
</svg>
</template>

View File

@@ -0,0 +1,89 @@
// useDataLoader.js
import { ref, readonly } from "vue";
import { useToast } from "primevue/usetoast";
export function useRdocLoader() {
const toast = useToast();
const rdocData = ref(null);
const isValidVersion = ref(false);
const MIN_SUPPORTED_VERSION = "7.0.0";
/**
* Checks if the loaded rdoc version is supported
* @param {Object} rdoc - The loaded JSON rdoc data
* @returns {boolean} - True if version is supported, false otherwise
*/
const checkVersion = (rdoc) => {
const version = rdoc.meta.version;
if (version < MIN_SUPPORTED_VERSION) {
console.error(
`Version ${version} is not supported. Please use version ${MIN_SUPPORTED_VERSION} or higher.`
);
toast.add({
severity: "error",
summary: "Unsupported Version",
detail: `Version ${version} is not supported. Please use version ${MIN_SUPPORTED_VERSION} or higher.`,
life: 5000,
group: "bc" // bottom-center
});
return false;
}
return true;
};
/**
* Loads JSON rdoc data from various sources
* @param {File|string|Object} source - File object, URL string, or JSON object
* @returns {Promise<void>}
*/
const loadRdoc = async (source) => {
try {
let data;
if (typeof source === "string") {
// Load from URL
const response = await fetch(source);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
data = await response.json();
} else if (typeof source === "object") {
// Direct JSON object (Preview options)
data = source;
} else {
throw new Error("Invalid source type");
}
if (checkVersion(data)) {
rdocData.value = data;
isValidVersion.value = true;
toast.add({
severity: "success",
summary: "Success",
detail: "JSON data loaded successfully",
life: 3000,
group: "bc" // bottom-center
});
} else {
rdocData.value = null;
isValidVersion.value = false;
}
} catch (error) {
console.error("Error loading JSON:", error);
toast.add({
severity: "error",
summary: "Error",
detail: "Failed to process the file. Please ensure it's a valid JSON or gzipped JSON file.",
life: 3000,
group: "bc" // bottom-center
});
}
};
return {
rdocData: readonly(rdocData),
isValidVersion: readonly(isValidVersion),
loadRdoc
};
}

88
web/explorer/src/main.js Normal file
View File

@@ -0,0 +1,88 @@
import "primeicons/primeicons.css";
import "./assets/main.css";
import "highlight.js/styles/default.css";
import "primeflex/primeflex.css";
import "primeflex/themes/primeone-light.css";
import "highlight.js/lib/common";
import hljsVuePlugin from "@highlightjs/vue-plugin";
import { createApp } from "vue";
import PrimeVue from "primevue/config";
import Ripple from "primevue/ripple";
import Aura from "@primevue/themes/aura";
import App from "./App.vue";
import MenuBar from "primevue/menubar";
import Card from "primevue/card";
import Panel from "primevue/panel";
import Column from "primevue/column";
import Checkbox from "primevue/checkbox";
import FloatLabel from "primevue/floatlabel";
import Tooltip from "primevue/tooltip";
import Divider from "primevue/divider";
import ContextMenu from "primevue/contextmenu";
import ToastService from "primevue/toastservice";
import Toast from "primevue/toast";
import router from "./router";
import { definePreset } from "@primevue/themes";
const Noir = definePreset(Aura, {
semantic: {
primary: {
50: "{zinc.50}",
100: "{zinc.100}",
200: "{zinc.200}",
300: "{zinc.300}",
400: "{zinc.400}",
500: "{zinc.500}",
600: "{zinc.600}",
700: "{zinc.700}",
800: "{zinc.800}",
900: "{zinc.900}",
950: "{zinc.950}"
},
colorScheme: {
light: {
primary: {
color: "{slate.800}",
inverseColor: "#ffffff",
hoverColor: "{zinc.900}",
activeColor: "{zinc.800}"
}
}
}
}
});
const app = createApp(App);
app.use(router);
app.use(hljsVuePlugin);
app.use(PrimeVue, {
theme: {
preset: Noir,
options: {
darkModeSelector: "light"
}
},
ripple: true
});
app.use(ToastService);
app.directive("tooltip", Tooltip);
app.directive("ripple", Ripple);
app.component("Card", Card);
app.component("Divider", Divider);
app.component("Toast", Toast);
app.component("Panel", Panel);
app.component("MenuBar", MenuBar);
app.component("Checkbox", Checkbox);
app.component("FloatLabel", FloatLabel);
app.component("Column", Column);
app.component("ContextMenu", ContextMenu);
app.mount("#app");

View File

@@ -0,0 +1,22 @@
import { createRouter, createWebHashHistory } from "vue-router";
import ImportView from "../views/ImportView.vue";
import NotFoundView from "../views/NotFoundView.vue";
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: ImportView
},
// 404 Route - This should be the last route
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: NotFoundView
}
]
});
export default router;

View File

@@ -0,0 +1,301 @@
import { describe, it, expect } from "vitest";
import { parseRules, parseFunctionCapabilities } from "../utils/rdocParser";
describe("parseRules", () => {
it("should return an empty array for empty rules", () => {
const rules = {};
const flavor = "static";
const layout = {};
const result = parseRules(rules, flavor, layout);
expect(result).toEqual([]);
});
it("should correctly parse a simple rule with static scope", () => {
const rules = {
"test rule": {
meta: {
name: "test rule",
namespace: "test",
lib: false,
scopes: {
static: "function",
dynamic: "process"
}
},
source: "test rule source",
matches: [
[
{ type: "absolute", value: 0x1000 },
{
success: true,
node: { type: "feature", feature: { type: "api", api: "TestAPI" } },
children: [],
locations: [{ type: "absolute", value: 0x1000 }],
captures: {}
}
]
]
}
};
const result = parseRules(rules, "static", {});
expect(result).toHaveLength(1);
expect(result[0].key).toBe("0");
expect(result[0].data.type).toBe("rule");
expect(result[0].data.name).toBe("test rule");
expect(result[0].data.lib).toBe(false);
expect(result[0].data.namespace).toBe("test");
expect(result[0].data.source).toBe("test rule source");
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].key).toBe("0-0");
expect(result[0].children[0].data.type).toBe("match location");
expect(result[0].children[0].children[0].data.type).toBe("feature");
expect(result[0].children[0].children[0].data.typeValue).toBe("api");
expect(result[0].children[0].children[0].data.name).toBe("TestAPI");
});
it('should handle rule with "not" statements correctly', () => {
const rules = {
"test rule": {
meta: {
name: "test rule",
namespace: "test",
lib: false,
scopes: {
static: "function",
dynamic: "process"
}
},
source: "test rule source",
matches: [
[
{ type: "absolute", value: 0x1000 },
{
success: true,
node: { type: "statement", statement: { type: "not" } },
children: [
{ success: false, node: { type: "feature", feature: { type: "api", api: "TestAPI" } } }
]
}
]
]
}
};
const result = parseRules(rules, "static", {});
expect(result).toHaveLength(1);
expect(result[0].children[0].children[0].data.type).toBe("statement");
expect(result[0].children[0].children[0].data.name).toBe("not:");
expect(result[0].children[0].children[0].children[0].data.type).toBe("feature");
expect(result[0].children[0].children[0].children[0].data.typeValue).toBe("api");
expect(result[0].children[0].children[0].children[0].data.name).toBe("TestAPI");
});
});
describe("parseFunctionCapabilities", () => {
it("should return an empty array when no functions match", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: []
}
}
},
rules: {}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toEqual([]);
});
it("should parse a single function with one rule match", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: [
{
address: {
type: "absolute",
value: 0x1000
},
matched_basic_blocks: [
{
address: {
type: "absolute",
value: 0x1000
}
}
]
}
]
}
}
},
rules: {
rule1: {
meta: {
name: "Test Rule",
namespace: "test",
lib: false,
scopes: { static: "function" }
},
matches: [[{ value: 0x1000 }]]
}
}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
funcaddr: "0x1000",
matchCount: 1,
ruleName: "Test Rule",
ruleMatchCount: 1,
namespace: "test",
lib: false
});
});
it("should handle multiple rules matching a single function", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: [
{
address: {
type: "absolute",
value: 0x1000
},
matched_basic_blocks: []
}
]
}
}
},
rules: {
rule1: {
meta: {
name: "Rule 1",
namespace: "test1",
lib: false,
scopes: { static: "function" }
},
matches: [[{ value: 0x1000 }]]
},
rule2: {
meta: {
name: "Rule 2",
namespace: "test2",
lib: false,
scopes: { static: "function" }
},
matches: [[{ value: 0x1000 }]]
}
}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toHaveLength(2);
expect(result[0].funcaddr).toBe("0x1000");
expect(result[1].funcaddr).toBe("0x1000");
expect(result.map((r) => r.ruleName)).toEqual(["Rule 1", "Rule 2"]);
});
it("should handle library rules correctly", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: [
{
address: { type: "absolute", value: 0x1000 },
matched_basic_blocks: []
}
]
}
}
},
rules: {
libRule: {
meta: {
name: "Lib Rule",
namespace: "lib",
lib: true,
scopes: { static: "function" }
},
matches: [[{ value: 0x1000 }]]
}
}
};
const resultWithLib = parseFunctionCapabilities(mockData, true);
expect(resultWithLib).toHaveLength(1);
expect(resultWithLib[0].lib).toBe(true);
const resultWithoutLib = parseFunctionCapabilities(mockData, false);
expect(resultWithoutLib).toHaveLength(0);
});
it("should handle a single rule matching in multiple functions", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: [
{ address: { value: 0x1000 }, matched_basic_blocks: [] },
{ address: { value: 0x2000 }, matched_basic_blocks: [] }
]
}
}
},
rules: {
rule1: {
meta: {
name: "Multi-function Rule",
namespace: "test",
lib: false,
scopes: { static: "function" }
},
matches: [[{ value: 0x1000 }], [{ value: 0x2000 }]]
}
}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toHaveLength(2);
expect(result[0].funcaddr).toBe("0x1000");
expect(result[0].ruleName).toBe("Multi-function Rule");
expect(result[0].ruleMatchCount).toBe(1);
expect(result[1].funcaddr).toBe("0x2000");
expect(result[1].ruleName).toBe("Multi-function Rule");
expect(result[1].ruleMatchCount).toBe(1);
});
it("should handle basic block scoped rules", () => {
const mockData = {
meta: {
analysis: {
layout: {
functions: [
{
address: { value: 0x1000 },
matched_basic_blocks: [{ address: { value: 0x1010 } }]
}
]
}
}
},
rules: {
bbRule: {
meta: {
name: "Basic Block Rule",
namespace: "test",
lib: false,
scopes: { static: "basic block" }
},
matches: [[{ value: 0x1010 }]]
}
}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toHaveLength(1);
expect(result[0].funcaddr).toBe("0x1000");
expect(result[0].ruleName).toBe("Basic Block Rule");
});
});

View File

@@ -0,0 +1,38 @@
import pako from "pako";
/**
* Checks if the given file is gzipped
* @param {File} file - The file to check
* @returns {Promise<boolean>} - True if the file is gzipped, false otherwise
*/
export const isGzipped = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return uint8Array[0] === 0x1f && uint8Array[1] === 0x8b;
};
/**
* Decompresses a gzipped file
* @param {File} file - The gzipped file to decompress
* @returns {Promise<string>} - The decompressed file content as a string
*/
export const decompressGzip = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const decompressed = pako.inflate(uint8Array, { to: "string" });
return decompressed;
};
/**
* Reads a file as text
* @param {File} file - The file to read
* @returns {Promise<string>} - The file content as a string
*/
export const readFileAsText = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = (error) => reject(error);
reader.readAsText(file);
});
};

View File

@@ -0,0 +1,631 @@
/**
* Parses rules data for the CapaTreeTable component
* @param {Object} rules - The rules object from the rodc JSON data
* @param {string} flavor - The flavor of the analysis (static or dynamic)
* @param {Object} layout - The layout object from the rdoc JSON data
* @param {number} [maxMatches=500] - Maximum number of matches to parse per rule
* @returns {Array} - Parsed tree data for the TreeTable component
*/
export function parseRules(rules, flavor, layout, maxMatches = 1) {
return Object.entries(rules).map(([, rule], index) => {
const ruleNode = {
key: `${index}`,
data: {
type: "rule",
name: rule.meta.name,
lib: rule.meta.lib,
matchCount: rule.matches.length,
namespace: rule.meta.namespace,
mbc: rule.meta.mbc,
source: rule.source,
tactic: JSON.stringify(rule.meta.attack),
attack: rule.meta.attack
? rule.meta.attack.map((attack) => ({
tactic: attack.tactic,
technique: attack.technique,
id: attack.id.includes(".") ? attack.id.split(".")[0] : attack.id,
techniques: attack.subtechnique ? [{ technique: attack.subtechnique, id: attack.id }] : []
}))
: null
}
};
// Is this a static rule with a file-level scope?
const isFileScope = rule.meta.scopes && rule.meta.scopes.static === "file";
// Limit the number of matches to process
// Dynamic matches can have thousands of matches, only show `maxMatches` for performance reasons
const limitedMatches = flavor === "dynamic" ? rule.matches.slice(0, maxMatches) : rule.matches;
if (isFileScope) {
// The scope for the rule is a file, so we don't need to show the match location address
ruleNode.children = limitedMatches.map((match, matchIndex) => {
return parseNode(match[1], `${index}-${matchIndex}`, rules, rule.meta.lib, layout);
});
} else {
// This is not a file-level match scope, we need to create intermediate nodes for each match
ruleNode.children = limitedMatches.map((match, matchIndex) => {
const matchKey = `${index}-${matchIndex}`;
const matchNode = {
key: matchKey,
data: {
type: "match location",
name:
flavor === "static"
? `${rule.meta.scopes.static} @ ` + formatAddress(match[0])
: getProcessName(layout, match[0])
},
children: [parseNode(match[1], `${matchKey}`, rules, rule.meta.lib, layout)]
};
return matchNode;
});
}
// Add a note if there are more matches than the limit
if (rule.matches.length > limitedMatches.length) {
ruleNode.children.push({
key: `${index}`,
data: {
type: "match location",
name: `... and ${rule.matches.length - maxMatches} more matches`
}
});
}
return ruleNode;
});
}
/**
* Parses rules data for the CapasByFunction component
* @param {Object} data - The full JSON data object containing analysis results
* @param {boolean} showLibraryRules - Whether to include library rules in the output
* @returns {Array} - Parsed data for the CapasByFunction DataTable component
*/
export function parseFunctionCapabilities(data, showLibraryRules) {
const result = [];
const matchesByFunction = new Map();
// Create a map of basic blocks to functions
const functionsByBB = new Map();
for (const func of data.meta.analysis.layout.functions) {
const funcAddress = func.address.value;
for (const bb of func.matched_basic_blocks) {
functionsByBB.set(bb.address.value, funcAddress);
}
}
// Iterate through all rules in the data
for (const ruleId in data.rules) {
const rule = data.rules[ruleId];
// Skip library rules if showLibraryRules is false
if (!showLibraryRules && rule.meta.lib) {
continue;
}
if (rule.meta.scopes.static === "function") {
// Function scope
for (const [addr] of rule.matches) {
const funcAddr = addr.value;
if (!matchesByFunction.has(funcAddr)) {
matchesByFunction.set(funcAddr, new Map());
}
const funcMatches = matchesByFunction.get(funcAddr);
funcMatches.set(rule.meta.name, {
count: (funcMatches.get(rule.meta.name)?.count || 0) + 1,
namespace: rule.meta.namespace,
lib: rule.meta.lib
});
}
} else if (rule.meta.scopes.static === "basic block") {
// Basic block scope
for (const [addr] of rule.matches) {
const bbAddr = addr.value;
const funcAddr = functionsByBB.get(bbAddr);
if (funcAddr) {
if (!matchesByFunction.has(funcAddr)) {
matchesByFunction.set(funcAddr, new Map());
}
const funcMatches = matchesByFunction.get(funcAddr);
funcMatches.set(rule.meta.name, {
count: (funcMatches.get(rule.meta.name)?.count || 0) + 1,
namespace: rule.meta.namespace,
lib: rule.meta.lib
});
}
}
}
}
// Convert the matchesByFunction map to the intermediate result array
for (const [funcAddr, matches] of matchesByFunction) {
const functionAddress = funcAddr.toString(16).toUpperCase();
const matchingRules = Array.from(matches, ([ruleName, data]) => ({
ruleName,
matchCount: data.count,
namespace: data.namespace,
lib: data.lib
}));
result.push({
funcaddr: `0x${functionAddress}`,
matchCount: matchingRules.length,
capabilities: matchingRules,
lib: data.lib
});
}
// Transform the intermediate result into the final format
const finalResult = result.flatMap((func) =>
func.capabilities.map((cap) => ({
funcaddr: func.funcaddr,
matchCount: func.matchCount,
ruleName: cap.ruleName,
ruleMatchCount: cap.matchCount,
namespace: cap.namespace,
lib: cap.lib
}))
);
return finalResult;
}
// Helper functions
/**
* Parses a single `node` object (i.e. statement or feature) in each rule
* @param {Object} node - The node to parse
* @param {string} key - The key for this node
* @param {Object} rules - The full rules object
* @param {boolean} lib - Whether this is a library rule
* @returns {Object} - Parsed node data
*/
function parseNode(node, key, rules, lib, layout) {
if (!node) return null;
const isNotStatement = node.node.statement && node.node.statement.type === "not";
const processedNode = isNotStatement ? invertNotStatementSuccess(node) : node;
if (!processedNode.success) {
return null;
}
const result = {
key: key,
data: {
type: processedNode.node.type, // statement or feature
typeValue: processedNode.node.statement
? processedNode.node.statement.type
: processedNode.node.feature.type, // type value (eg. number, regex, api, or, and, optional ... etc)
success: processedNode.success,
name: getNodeName(processedNode),
lib: lib,
address: getNodeAddress(processedNode),
description: getNodeDescription(processedNode),
namespace: null,
matchCount: null,
source: null
},
children: []
};
// Recursively parse children
if (processedNode.children && Array.isArray(processedNode.children)) {
result.children = processedNode.children
.map((child) => {
const childNode = parseNode(child, `${key}`, rules, lib, layout);
return childNode;
})
.filter((child) => child !== null);
}
// If this is a match node, add the rule's source code to the result.data.source object
if (processedNode.node.feature && processedNode.node.feature.type === "match") {
const ruleName = processedNode.node.feature.match;
const rule = rules[ruleName];
if (rule) {
result.data.source = rule.source;
}
result.children = [];
}
// If this is an optional node, check if it has children. If not, return null (optional statement always evaluate to true)
// we only render them, if they have at least one child node where node.success is true.
if (processedNode.node.statement && processedNode.node.statement.type === "optional") {
if (result.children.length === 0) return null;
}
if (processedNode.node.feature && processedNode.node.feature.type === "regex") {
result.children = processRegexCaptures(processedNode, key);
}
// Add call information for dynamic sandbox traces
if (processedNode.node.feature && processedNode.node.feature.type === "api") {
const callInfo = getCallInfo(node, layout);
if (callInfo) {
result.children.push({
key: key,
data: {
type: "call-info",
name: callInfo
},
children: []
});
}
}
return result;
}
// TODO(s-ff): decide if we want to show call info or not
// e.g. explorer.exe{id:0,tid:10,pid:100,ppid:1000}
function getCallInfo(node, layout) {
if (!node.locations || node.locations.length === 0) return null;
const location = node.locations[0];
if (location.type !== "call") return null;
// eslint-disable-next-line no-unused-vars
const [ppid, pid, tid, callId] = location.value;
// eslint-disable-next-line no-unused-vars
const callName = node.node.feature.api;
const pname = getProcessName(layout, location);
const cname = getCallName(layout, location);
// eslint-disable-next-line no-unused-vars
const [fname, separator, restWithArgs] = partition(cname, "(");
const [args, , returnValueWithParen] = rpartition(restWithArgs, ")");
const s = [];
s.push(`${fname}(`);
for (const arg of args.split(", ")) {
s.push(` ${arg},`);
}
s.push(`)${returnValueWithParen}`);
//const callInfo = `${pname}{pid:${pid},tid:${tid},call:${callId}}\n${s.join('\n')}`;
return { processName: pname, callInfo: s.join("\n") };
}
/**
* Splits a string into three parts based on the first occurrence of a separator.
* This function mimics Python's str.partition() method.
*
* @param {string} str - The input string to be partitioned.
* @param {string} separator - The separator to use for partitioning.
* @returns {Array<string>} An array containing three elements:
* 1. The part of the string before the separator.
* 2. The separator itself.
* 3. The part of the string after the separator.
* If the separator is not found, returns [str, '', ''].
*
* @example
* // Returns ["hello", ",", "world"]
* partition("hello,world", ",");
*
* @example
* // Returns ["hello world", "", ""]
* partition("hello world", ":");
*/
function partition(str, separator) {
const index = str.indexOf(separator);
if (index === -1) {
// Separator not found, return original string and two empty strings
return [str, "", ""];
}
return [str.slice(0, index), separator, str.slice(index + separator.length)];
}
/**
* Get the process name from the layout
* @param {Object} layout - The layout object
* @param {Object} address - The address object containing process information
* @returns {string} The process name
*/
function getProcessName(layout, address) {
if (!layout || !layout.processes || !Array.isArray(layout.processes)) {
console.error("Invalid layout structure");
return "Unknown Process";
}
const [ppid, pid] = address.value;
for (const process of layout.processes) {
if (
process.address &&
process.address.type === "process" &&
process.address.value &&
process.address.value[0] === ppid &&
process.address.value[1] === pid
) {
return process.name || "Unnamed Process";
}
}
return "Unknown Process";
}
/**
* Splits a string into three parts based on the last occurrence of a separator.
* This function mimics Python's str.rpartition() method.
*
* @param {string} str - The input string to be partitioned.
* @param {string} separator - The separator to use for partitioning.
* @returns {Array<string>} An array containing three elements:
* 1. The part of the string before the last occurrence of the separator.
* 2. The separator itself.
* 3. The part of the string after the last occurrence of the separator.
* If the separator is not found, returns ['', '', str].
*
* @example
* // Returns ["hello,", ",", "world"]
* rpartition("hello,world,", ",");
*
* @example
* // Returns ["", "", "hello world"]
* rpartition("hello world", ":");
*/
function rpartition(str, separator) {
const index = str.lastIndexOf(separator);
if (index === -1) {
// Separator not found, return two empty strings and the original string
return ["", "", str];
}
return [
str.slice(0, index), // Part before the last separator
separator, // The separator itself
str.slice(index + separator.length) // Part after the last separator
];
}
/**
* Get the call name from the layout
* @param {Object} layout - The layout object
* @param {Object} address - The address object containing call information
* @returns {string} The call name with arguments
*/
function getCallName(layout, address) {
if (!layout || !layout.processes || !Array.isArray(layout.processes)) {
console.error("Invalid layout structure");
return "Unknown Call";
}
const [ppid, pid, tid, callId] = address.value;
for (const process of layout.processes) {
if (
process.address &&
process.address.type === "process" &&
process.address.value &&
process.address.value[0] === ppid &&
process.address.value[1] === pid
) {
for (const thread of process.matched_threads) {
if (
thread.address &&
thread.address.type === "thread" &&
thread.address.value &&
thread.address.value[2] === tid
) {
for (const call of thread.matched_calls) {
if (
call.address &&
call.address.type === "call" &&
call.address.value &&
call.address.value[3] === callId
) {
return call.name || "Unnamed Call";
}
}
}
}
}
}
return "Unknown Call";
}
function processRegexCaptures(node, key) {
if (!node.captures) return [];
return Object.entries(node.captures).map(([capture, locations]) => ({
key: key,
data: {
type: "regex-capture",
name: `"${escape(capture)}"`,
address: formatAddress(locations[0])
}
}));
}
function formatAddress(address) {
switch (address.type) {
case "absolute":
return formatHex(address.value);
case "relative":
return `base address+${formatHex(address.value)}`;
case "file":
return `file+${formatHex(address.value)}`;
case "dn_token":
return `token(${formatHex(address.value)})`;
case "dn_token_offset": {
const [token, offset] = address.value;
return `token(${formatHex(token)})+${formatHex(offset)}`;
}
case "process":
//const [ppid, pid] = address.value;
//return `process{pid:${pid}}`;
return formatDynamicAddress(address.value);
case "thread":
//const [threadPpid, threadPid, tid] = address.value;
//return `process{pid:${threadPid},tid:${tid}}`;
return formatDynamicAddress(address.value);
case "call":
//const [callPpid, callPid, callTid, id] = address.value;
//return `process{pid:${callPid},tid:${callTid},call:${id}}`;
return formatDynamicAddress(address.value);
case "no address":
return "";
default:
throw new Error("Unexpected address type");
}
}
function escape(str) {
return str.replace(/"/g, '\\"');
}
/**
* Inverts the success values for children of a 'not' statement
* @param {Object} node - The node to invert
* @returns {Object} The inverted node
*/
function invertNotStatementSuccess(node) {
if (!node) return null;
return {
...node,
children: node.children
? node.children.map((child) => ({
...child,
success: !child.success,
children: child.children ? invertNotStatementSuccess(child).children : []
}))
: []
};
}
/**
* Gets the description of a node
* @param {Object} node - The node to get the description from
* @returns {string|null} The description or null if not found
*/
function getNodeDescription(node) {
if (node.node.statement) {
return node.node.statement.description;
} else if (node.node.feature) {
return node.node.feature.description;
} else {
return null;
}
}
/**
* Gets the name of a node
* @param {Object} node - The node to get the name from
* @returns {string} The name of the node
*/
function getNodeName(node) {
if (node.node.statement) {
return getStatementName(node.node.statement);
} else if (node.node.feature) {
return getFeatureName(node.node.feature);
}
return null;
}
/**
* Gets the name for a statement node
* @param {Object} statement - The statement object
* @returns {string} The name of the statement
*/
function getStatementName(statement) {
switch (statement.type) {
case "subscope":
// for example, "basic block: "
return `${statement.scope}:`;
case "range":
return getRangeName(statement);
case "some":
return `${statement.count} or more`;
default:
// statement (e.g. "and: ", "or: ", "optional:", ... etc)
return `${statement.type}:`;
}
}
/**
* Gets the name for a feature node
* @param {Object} feature - The feature object
* @returns {string} The name of the feature
*/
function getFeatureName(feature) {
switch (feature.type) {
case "number":
case "offset":
// example: "number: 0x1234", "offset: 0x3C"
// return `${feature.type}: 0x${feature[feature.type].toString(16).toUpperCase()}`
return `0x${feature[feature.type].toString(16).toUpperCase()}`;
case "bytes":
return formatBytes(feature.bytes);
case "operand offset":
return `operand[${feature.index}].offset: 0x${feature.operand_offset.toString(16).toUpperCase()}`;
default:
return `${feature[feature.type]}`;
}
}
/**
* Formats the name for a range statement
* @param {Object} statement - The range statement object
* @returns {string} The formatted range name
*/
function getRangeName(statement) {
const { child, min, max } = statement;
const { type, [type]: value } = child;
const rangeType = value || value === 0 ? `count(${type}(${value}))` : `count(${type})`;
let rangeValue;
if (min === max) {
rangeValue = `${min}`;
} else if (max >= Number.MAX_SAFE_INTEGER) {
rangeValue = `${min} or more`;
} else {
rangeValue = `between ${min} and ${max}`;
}
// for example: count(mnemonic(xor)): 2 or more
return `${rangeType}: ${rangeValue} `;
}
/**
* Gets the address of a node
* @param {Object} node - The node to get the address from
* @returns {string|null} The formatted address or null if not found
*/
function getNodeAddress(node) {
if (node.node.feature && node.node.feature.type === "regex") return null;
if (node.locations && node.locations.length > 0) {
return formatAddress(node.locations[0]);
}
return null;
}
/**
* Formats bytes string for display
* @param {Array} value - The bytes string
* @returns {string} - Formatted bytes string
*/
function formatBytes(byteString) {
// Use a regular expression to insert a space after every two characters
const formattedString = byteString.replace(/(.{2})/g, "$1 ").trim();
// convert to uppercase
return formattedString.toUpperCase();
}
/**
* Formats the address for dynamic flavor
* @param {Array} value - The address value array
* @returns {string} - Formatted address string
*/
function formatDynamicAddress(value) {
const parts = ["ppid", "pid", "tid", "id"];
return value
.map((item, index) => `${parts[index]}:${item}`)
.reverse()
.join(",");
}
function formatHex(address) {
return `0x${address.toString(16).toUpperCase()}`;
}

View File

@@ -0,0 +1,52 @@
/**
* Creates an MBC (Malware Behavior Catalog) URL from an MBC object.
*
* @param {Object} mbc - The MBC object to format.
* @param {string} mbc.id - The ID of the MBC entry.
* @param {string} mbc.objective - The objective of the malware behavior.
* @param {string} mbc.behavior - The specific behavior of the malware.
* @returns {string|null} The MBC URL or null if the ID is invalid.
*/
export function createMBCHref(mbc) {
let baseUrl;
// Determine the base URL based on the id
if (mbc.id.startsWith("B")) {
// Behavior
baseUrl = "https://github.com/MBCProject/mbc-markdown/blob/main";
} else if (mbc.id.startsWith("C")) {
// Micro-Behavior
baseUrl = "https://github.com/MBCProject/mbc-markdown/blob/main/micro-behaviors";
} else {
return null;
}
// Convert the objective and behavior to lowercase and replace spaces with hyphens
const objectivePath = mbc.objective.toLowerCase().replace(/\s+/g, "-");
const behaviorPath = mbc.behavior.toLowerCase().replace(/\s+/g, "-");
// Construct the final URL
return `${baseUrl}/${objectivePath}/${behaviorPath}.md`;
}
/**
* Creates a MITRE ATT&CK URL for a specific technique or sub-technique.
*
* @param {Object} attack - The ATT&CK object containing information about the technique.
* @param {string} attack.id - The ID of the ATT&CK technique or sub-technique.
* @returns {string|null} The formatted MITRE ATT&CK URL for the technique or null if the ID is invalid.
*/
export function createATTACKHref(attack) {
const baseUrl = "https://attack.mitre.org/techniques/";
const idParts = attack.id.split(".");
if (idParts.length === 1) {
// It's a technique
return `${baseUrl}${idParts[0]}`;
} else if (idParts.length === 2) {
// It's a sub-technique
return `${baseUrl}${idParts[0]}/${idParts[1]}`;
} else {
return null;
}
}

View File

@@ -0,0 +1,120 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import DescriptionPanel from "../components/DescriptionPanel.vue";
import UploadOptions from "../components/UploadOptions.vue";
import MetadataPanel from "../components/MetadataPanel.vue";
import RuleMatchesTable from "../components/RuleMatchesTable.vue";
import FunctionCapabilities from "../components/FunctionCapabilities.vue";
import ProcessCapabilities from "../components/ProcessCapabilities.vue";
import SettingsPanel from "../components/SettingsPanel.vue";
import NamespaceChart from "../components/NamespaceChart.vue";
import Toast from "primevue/toast";
import demoRdocStatic from "../../../tests/data/rd/al-khaser_x64.exe_.json";
import demoRdocDynamic from "../../../tests/data/rd/0000a65749f5902c4d82ffa701198038f0b4870b00a27cfca109f8f933476d82.json";
import { useRdocLoader } from "../composables/useRdocLoader";
const { rdocData, isValidVersion, loadRdoc } = useRdocLoader();
import { isGzipped, decompressGzip, readFileAsText } from "../utils/fileUtils";
const showCapabilitiesByFunctionOrProcess = ref(false);
const showLibraryRules = ref(false);
const showNamespaceChart = ref(false);
const flavor = computed(() => rdocData.value?.meta.flavor);
const libraryRuleMatchesCount = computed(() => {
if (!rdocData.value || !rdocData.value.rules) return 0;
return Object.values(rdocData.value.rules).filter((rule) => rule.meta.lib).length;
});
const updateShowCapabilitiesByFunctionOrProcess = (value) => {
showCapabilitiesByFunctionOrProcess.value = value;
};
const updateShowLibraryRules = (value) => {
showLibraryRules.value = value;
};
const updateShowNamespaceChart = (value) => {
showNamespaceChart.value = value;
};
const loadFromLocal = async (event) => {
const file = event.files[0];
let fileContent;
if (await isGzipped(file)) {
fileContent = await decompressGzip(file);
} else {
fileContent = await readFileAsText(file);
}
const jsonData = JSON.parse(fileContent);
loadRdoc(jsonData);
};
const loadFromURL = (url) => {
loadRdoc(url);
};
const loadDemoDataStatic = () => {
loadRdoc(demoRdocStatic);
};
const loadDemoDataDynamic = () => {
loadRdoc(demoRdocDynamic);
};
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
const rdocURL = urlParams.get("rdoc");
if (rdocURL) {
loadFromURL(rdocURL);
}
});
</script>
<template>
<Panel v-if="!rdocData || !isValidVersion">
<DescriptionPanel />
<UploadOptions
@load-from-local="loadFromLocal"
@load-from-url="loadFromURL"
@load-demo-static="loadDemoDataStatic"
@load-demo-dynamic="loadDemoDataDynamic"
/>
</Panel>
<Toast position="bottom-center" group="bc" />
<template v-if="rdocData && isValidVersion">
<MetadataPanel :data="rdocData" />
<SettingsPanel
:flavor="flavor"
:library-rule-matches-count="libraryRuleMatchesCount"
@update:show-capabilities-by-function-or-process="updateShowCapabilitiesByFunctionOrProcess"
@update:show-library-rules="updateShowLibraryRules"
@update:show-namespace-chart="updateShowNamespaceChart"
/>
<RuleMatchesTable
v-if="!showCapabilitiesByFunctionOrProcess && !showNamespaceChart"
:data="rdocData"
:show-library-rules="showLibraryRules"
/>
<FunctionCapabilities
v-if="flavor === 'static' && showCapabilitiesByFunctionOrProcess && !showNamespaceChart"
:data="rdocData"
:show-library-rules="showLibraryRules"
/>
<ProcessCapabilities
v-else-if="flavor === 'dynamic' && showCapabilitiesByFunctionOrProcess && !showNamespaceChart"
:data="rdocData"
:show-capabilities-by-process="showCapabilitiesByFunctionOrProcess"
:show-library-rules="showLibraryRules"
/>
<NamespaceChart v-else-if="showNamespaceChart" :data="rdocData" />
</template>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex flex-column align-items-center justify-content-center min-h-screen bg-blue-50">
<h1 class="text-900 font-bold text-8xl mb-4">404</h1>
<p class="text-600 text-3xl mb-5">Oops! The page you're looking for doesn't exist.</p>
<Button label="Go Home" icon="pi pi-home" @click="goHome" />
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import Button from "primevue/button";
const router = useRouter();
const goHome = () => {
router.push("/");
};
</script>

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteSingleFile } from 'vite-plugin-singlefile'
// eslint-disable-next-line no-unused-vars
export default defineConfig(({ command, mode }) => {
const isBundle = mode === 'bundle'
return {
base: isBundle ? '/' : '/capa/',
plugins: isBundle ? [vue(), viteSingleFile()] : [vue()]
}
})

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
}
})