mirror of
https://github.com/mandiant/capa.git
synced 2025-12-12 07:40:38 -08:00
move webui to web/explorer
This commit is contained in:
13
web/explorer/.eslintrc.cjs
Normal file
13
web/explorer/.eslintrc.cjs
Normal 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
28
web/explorer/.gitignore
vendored
Normal 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
|
||||
8
web/explorer/.prettierrc.json
Normal file
8
web/explorer/.prettierrc.json
Normal 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
41
web/explorer/README.md
Normal 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
13
web/explorer/index.html
Normal 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>
|
||||
8
web/explorer/jsconfig.json
Normal file
8
web/explorer/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
39
web/explorer/package.json
Normal file
39
web/explorer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web/explorer/public/favicon.ico
Normal file
BIN
web/explorer/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
15
web/explorer/src/App.vue
Normal file
15
web/explorer/src/App.vue
Normal 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>
|
||||
BIN
web/explorer/src/assets/images/icon.png
Normal file
BIN
web/explorer/src/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
web/explorer/src/assets/images/logo-full.png
Normal file
BIN
web/explorer/src/assets/images/logo-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
20
web/explorer/src/assets/main.css
Normal file
20
web/explorer/src/assets/main.css
Normal 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;
|
||||
}
|
||||
45
web/explorer/src/components/BannerHeader.vue
Normal file
45
web/explorer/src/components/BannerHeader.vue
Normal 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>
|
||||
16
web/explorer/src/components/DescriptionPanel.vue
Normal file
16
web/explorer/src/components/DescriptionPanel.vue
Normal 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>
|
||||
81
web/explorer/src/components/FunctionCapabilities.vue
Normal file
81
web/explorer/src/components/FunctionCapabilities.vue
Normal 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>
|
||||
109
web/explorer/src/components/MetadataPanel.vue
Normal file
109
web/explorer/src/components/MetadataPanel.vue
Normal 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>
|
||||
126
web/explorer/src/components/NamespaceChart.vue
Normal file
126
web/explorer/src/components/NamespaceChart.vue
Normal 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>
|
||||
31
web/explorer/src/components/NavBar.vue
Normal file
31
web/explorer/src/components/NavBar.vue
Normal 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>
|
||||
223
web/explorer/src/components/ProcessCapabilities.vue
Normal file
223
web/explorer/src/components/ProcessCapabilities.vue
Normal 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>
|
||||
289
web/explorer/src/components/RuleMatchesTable.vue
Normal file
289
web/explorer/src/components/RuleMatchesTable.vue
Normal 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>
|
||||
77
web/explorer/src/components/SettingsPanel.vue
Normal file
77
web/explorer/src/components/SettingsPanel.vue
Normal 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>
|
||||
91
web/explorer/src/components/UploadOptions.vue
Normal file
91
web/explorer/src/components/UploadOptions.vue
Normal 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>
|
||||
52
web/explorer/src/components/columns/RuleColumn.vue
Normal file
52
web/explorer/src/components/columns/RuleColumn.vue
Normal 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>
|
||||
13
web/explorer/src/components/misc/LibraryTag.vue
Normal file
13
web/explorer/src/components/misc/LibraryTag.vue
Normal 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>
|
||||
5
web/explorer/src/components/misc/VTIcon.vue
Normal file
5
web/explorer/src/components/misc/VTIcon.vue
Normal 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>
|
||||
89
web/explorer/src/composables/useRdocLoader.js
Normal file
89
web/explorer/src/composables/useRdocLoader.js
Normal 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
88
web/explorer/src/main.js
Normal 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");
|
||||
22
web/explorer/src/router/index.js
Normal file
22
web/explorer/src/router/index.js
Normal 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;
|
||||
301
web/explorer/src/tests/rdocParser.test.js
Normal file
301
web/explorer/src/tests/rdocParser.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
38
web/explorer/src/utils/fileUtils.js
Normal file
38
web/explorer/src/utils/fileUtils.js
Normal 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);
|
||||
});
|
||||
};
|
||||
631
web/explorer/src/utils/rdocParser.js
Normal file
631
web/explorer/src/utils/rdocParser.js
Normal 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()}`;
|
||||
}
|
||||
52
web/explorer/src/utils/urlHelpers.js
Normal file
52
web/explorer/src/utils/urlHelpers.js
Normal 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;
|
||||
}
|
||||
}
|
||||
120
web/explorer/src/views/ImportView.vue
Normal file
120
web/explorer/src/views/ImportView.vue
Normal 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>
|
||||
19
web/explorer/src/views/NotFoundView.vue
Normal file
19
web/explorer/src/views/NotFoundView.vue
Normal 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>
|
||||
13
web/explorer/vite.config.js
Normal file
13
web/explorer/vite.config.js
Normal 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()]
|
||||
}
|
||||
})
|
||||
12
web/explorer/vitest.config.js
Normal file
12
web/explorer/vitest.config.js
Normal 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}'],
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user