Merge branch 'master' into vmray-extractor

This commit is contained in:
Moritz
2024-08-09 13:58:45 +02:00
committed by GitHub
47 changed files with 7273 additions and 13 deletions
+4 -4
View File
@@ -1,16 +1,16 @@
# Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E", "F"]
lint.select = ["E", "F"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
lint.fixable = ["ALL"]
lint.unfixable = []
# E402 module level import not at top of file
# E722 do not use bare 'except'
# E501 line too long
ignore = ["E402", "E722", "E501"]
lint.ignore = ["E402", "E722", "E501"]
line-length = 120
+66
View File
@@ -0,0 +1,66 @@
name: deploy Capa Explorer Web to Github Pages
on:
# Runs on pushes targeting the webui branch
push:
branches: [ master ]
paths:
- 'web/explorer/**'
# Allows to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: 'pages'
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
fetch-depth: 1
show-progress: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: './web/explorer/package-lock.json'
- name: Install dependencies
run: npm ci
working-directory: ./web/explorer
- name: Lint
run: npm run lint
working-directory: ./web/explorer
- name: Format
run: npm run format:check
working-directory: ./web/explorer
- name: Run unit tests
run: npm run test
working-directory: ./web/explorer
- name: Build
run: npm run build
working-directory: ./web/explorer
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './web/explorer/dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+42
View File
@@ -0,0 +1,42 @@
name: Capa Explorer Web tests
on:
pull_request:
branches: [ master ]
paths:
- 'web/explorer/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
fetch-depth: 1
show-progress: true
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 20
cache: 'npm'
cache-dependency-path: './web/explorer/package-lock.json'
- name: Install dependencies
run: npm ci
working-directory: ./web/explorer
- name: Lint
run: npm run lint
working-directory: ./web/explorer
- name: Format
run: npm run format:check
working-directory: ./web/explorer
- name: Run unit tests
run: npm run test
working-directory: ./web/explorer
+6 -1
View File
@@ -5,12 +5,17 @@
Unlock powerful malware analysis with capa's new [VMRay sandbox](https://www.vmray.com/) integration! Simply provide a VMRay analysis archive, and capa will automatically extract and match capabilties, streamlining your workflow.
### New Features
- webui: explore capa analysis results in a web-based UI online and offline #2224 @s-ff
- support analyzing DRAKVUF traces #2143 @yelhamer
- dynamic: add support for VMRay dynamic sandbox traces #2208 @mike-hunhoff @r-sm2024 @mr-tz
### Breaking Changes
### New Rules (0)
### New Rules (2)
- nursery/upload-file-to-onedrive jaredswilson@google.com ervinocampo@google.com
- data-manipulation/encoding/base64/decode-data-using-base64-via-vbmi-lookup-table still@teamt5.org
-
### Bug Fixes
+1 -2
View File
@@ -11,7 +11,6 @@ import os
import re
import copy
import uuid
import codecs
import logging
import binascii
import collections
@@ -456,7 +455,7 @@ DESCRIPTION_SEPARATOR = " = "
def parse_bytes(s: str) -> bytes:
try:
b = codecs.decode(s.replace(" ", "").encode("ascii"), "hex")
b = bytes.fromhex(s.replace(" ", ""))
except binascii.Error:
raise InvalidRule(f'unexpected bytes value: must be a valid hex sequence: "{s}"')
+2 -2
View File
@@ -136,10 +136,10 @@ dev = [
"flake8-simplify==0.21.0",
"flake8-use-pathlib==0.3.0",
"flake8-copyright==0.2.4",
"ruff==0.5.2",
"ruff==0.5.6",
"black==24.4.2",
"isort==5.13.2",
"mypy==1.10.0",
"mypy==1.11.1",
"mypy-protobuf==3.6.0",
"PyGithub==2.3.0",
# type stubs for mypy
+2 -2
View File
@@ -22,7 +22,7 @@ msgpack==1.0.8
networkx==3.1
pefile==2023.2.7
pip==24.1.2
protobuf==5.27.1
protobuf==5.27.3
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.22
@@ -41,7 +41,7 @@ six==1.16.0
sortedcontainers==2.4.0
tabulate==0.9.0
termcolor==2.4.0
tqdm==4.66.4
tqdm==4.66.5
viv-utils==0.7.11
vivisect==1.1.1
wcwidth==0.2.13
+1 -1
Submodule rules updated: e63c454fbb...0e2500fa8a
+13
View File
@@ -0,0 +1,13 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: ["plugin:vue/vue3-essential", "eslint:recommended", "@vue/eslint-config-prettier/skip-formatting"],
parserOptions: {
ecmaVersion: "latest"
},
rules: {
"vue/multi-word-component-names": "off"
}
};
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies, build results, and other generated files
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.vscode
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TypeScript incremental build info
*.tsbuildinfo
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 4,
"singleQuote": false,
"printWidth": 120,
"trailingComma": "none"
}
+123
View File
@@ -0,0 +1,123 @@
# Development Guide for Capa Explorer Web
This guide will help you set up the Capa Explorer Web project for local development.
## Prerequisites
Before you begin, ensure you have the following installed:
- Node.js (v20.x or later recommended)
- npm (v10.x or later)
- Git
## Setting Up the Development Environment
1. Clone the repository:
```
git clone https://github.com/mandiat/capa.git
cd capa/web/explorer
```
2. Install dependencies:
```
npm install
```
3. Start the development server:
```
npm run dev
```
This will start the Vite development server. The application should now be running at `http://localhost:<port>`
## Project Structure
```
web/exporer/
├── src/
│ ├── assets/
│ ├── components/
│ ├── composables/
│ ├── router/
│ ├── utils/
│ ├── views/
│ ├── App.vue
│ └── main.js
├── public/
├── tests/
├── index.html
├── package.json
├── vite.config.js
├── DEVELOPMENT.md
└── README.md
```
- `src/`: Contains the source code of the application
- `src/components/`: Reusable Vue components
- `src/composables/`: Vue composition functions
- `src/router/`: Vue Router configuration
- `src/utils/`: Utility functions
- `src/views/`: Top-level views/pages
- `src/tests/`: Test files
- `public/`: Static assets that will be served as-is
## Building for Production
To build the application for production:
```
npm run build
```
This will generate production-ready files in the `dist/` directory.
Or, you can build a standalone bundle application that can be used offline:
```
npm run build:bundle
```
This will generate an offline HTML bundle file in the `dist/` directory.
## Testing
Run the test suite with:
```
npm run test
```
We use Vitest as our testing framework. Please ensure all tests pass before submitting a pull request.
## Linting and Formatting
We use ESLint for linting and Prettier for code formatting. Run the linter with:
```
npm run lint
npm run format
```
## Working with PrimeVue Components
Capa Explorer Web uses the PrimeVue UI component library. When adding new features or modifying existing ones, refer to the [PrimeVue documentation](https://primevue.org/vite) for available components and their usage.
## Best Practices
1. Follow the [Vue.js Style Guide](https://vuejs.org/style-guide/) for consistent code style.
2. Document new functions, components, and complex logic.
3. Write tests for new features and bug fixes.
4. Keep components small and focused on a single responsibility.
5. Use composables for reusable logic across components.
## Additional Resources
- [Vue.js Documentation](https://vuejs.org/guide/introduction.html)
- [Vite Documentation](https://vitejs.dev/guide/)
- [Vitest Documentation](https://vitest.dev/guide/)
- [PrimeVue Documentation](https://www.primevue.org/)
If you encounter any issues or have questions about the development process, please open an issue on the GitHub repository.
+41
View File
@@ -0,0 +1,41 @@
# Capa Explorer Web
Capa Explorer Web is a browser-based user interface for exploring program capabilities identified by capa. 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 and filter rule matches in a hierarchical structure.
- **Function Capabilities**: Group and filter capabilities by function for static analysis.
- **Process Capabilities**: Group capabilities by process for dynamic analysis.
## Getting Started
1. **Access the Application**: Open Capa Explorer Web in your web browser.
You can start using Capa Explorer Web by accessing [https://mandiant.github.io/capa](https://mandiant.github.io/capa/) or running it locally by dowloading the offline release in the [releases](https://github.com/mandiant/capa/releases) section and loading it in your browser.
2. **Import capa Results**:
- Click on "Upload from local" to select a capa analysis document 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 table to see more details.
- Use the search and filter options to find specific features, functions 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](DEVELOPMENT.md).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capa Explorer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+4133
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"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/",
"format:check": "prettier --check src/"
},
"dependencies": {
"@highlightjs/vue-plugin": "^2.1.0",
"@primevue/themes": "^4.0.0-rc.2",
"pako": "^2.1.0",
"plotly.js-dist": "^2.34.0",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.1.0",
"prettier": "^3.2.5",
"vite": "^5.3.1",
"vite-plugin-singlefile": "^2.0.2",
"vitest": "^1.6.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+28
View File
@@ -0,0 +1,28 @@
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);
}
.font-monospace {
font-family: monospace;
}
.cursor-default {
cursor: default;
}
/* remove the border from rows other than rule names */
.p-treetable-tbody > tr:not(:is([aria-level="1"])) > td {
border: none !important;
}
@@ -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>
@@ -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>
@@ -0,0 +1,123 @@
<template>
<DataTable
:value="tableData"
rowGroupMode="rowspan"
groupRowsBy="address"
removableSort
size="small"
:filters="filters"
:filterMode="filterMode"
:globalFilterFields="['funcaddr', 'ruleName', 'namespace']"
>
<template #header>
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Global search" />
</IconField>
</template>
<Column field="address" sortable header="Function Address" :rowspan="3" class="w-min">
<template #body="{ data }">
<span class="font-monospace">{{ data.address }}</span>
<span v-if="data.matchCount > 1" class="font-italic">
({{ data.matchCount }} match{{ data.matchCount > 1 ? "es" : "" }})
</span>
</template>
</Column>
<Column field="rule" sortable header="Matches" class="w-min">
<template #body="{ data }">
{{ data.rule }}
<LibraryTag v-if="data.lib" />
</template>
</Column>
<Column field="namespace" sortable header="Namespace"></Column>
</DataTable>
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
<highlightjs lang="yml" :code="currentSource" class="bg-white" />
</Dialog>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Dialog from "primevue/dialog";
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";
import InputText from "primevue/inputtext";
import LibraryTag from "@/components/misc/LibraryTag.vue";
import { parseFunctionCapabilities } from "@/utils/rdocParser";
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("");
const functionCapabilities = ref([]);
onMounted(() => {
const cacheKey = "functionCapabilities";
let cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
// If the data is already in sessionStorage, parse it and use it
functionCapabilities.value = JSON.parse(cachedData);
} else {
// Parse function capabilities and cache the result in sessionStorage
functionCapabilities.value = parseFunctionCapabilities(props.data);
try {
sessionStorage.setItem(cacheKey, JSON.stringify(functionCapabilities.value));
} catch (e) {
console.warn("Failed to store parsed data in sessionStorage:", e);
// If storing fails (e.g., due to storage limits), we can still continue with the parsed data
}
}
});
/*
* tableData is the data passed to the DataTable component
* it is a computed property (that is because it gets re-executed everytime props.showLibraryRules changes)
* it is an array of objects, where each object represents a row in the table
* it also converts the output of parseFunctionCapabilities into a format that can be used by the DataTable component
*/
const tableData = computed(() => {
const data = [];
for (const fcaps of functionCapabilities.value) {
const capabilities = fcaps.capabilities;
for (const capability of capabilities) {
if (capability.lib && !props.showLibraryRules) continue;
data.push({
address: fcaps.address,
matchCount: capabilities.length,
rule: capability.name,
namespace: capability.namespace,
lib: capability.lib
});
}
}
return data;
});
</script>
<style scoped>
/* tighten up the spacing between rows */
:deep(.p-datatable.p-datatable-sm .p-datatable-tbody > tr > td) {
padding: 0.2rem 0.5rem !important;
}
</style>
@@ -0,0 +1,109 @@
<template>
<!-- Main container with gradient background -->
<div
class="flex flex-wrap 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">
<h1 class="text-xl m-0 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">{{ sha256 }} </a>
</p>
</div>
<!-- Vertical divider -->
<div class="mx-3 bg-white-alpha-30 hidden sm:block" style="width: 1px; height: 30px"></div>
<!-- Analysis information section -->
<div class="flex-grow-1 mr-3">
<!-- OS Program Format Arch -->
<div class="flex align-items-center text-sm m-0 line-height-3 text-white">
<span class="capitalize">{{ data.meta.analysis.os }}</span>
<span class="ml-2 mr-2 text-white-alpha-30"> </span>
<span class="uppercase">{{ data.meta.analysis.format }}</span>
<span class="ml-2 mr-2 text-white-alpha-30"> </span>
<span>{{ data.meta.analysis.arch === "i386" ? "i386" : data.meta.analysis.arch.toUpperCase() }}</span>
</div>
<!-- Flavor Extractor CAPA Version Timestamp -->
<div class="flex-wrap align-items-center text-sm m-0 line-height-3 text-white">
<span class="capitalize">
{{ flavor }} analysis with {{ data.meta.analysis.extractor.split(/(Feature)?Extractor/)[0] }}</span
>
<!--- Extractor (e.g., CapeExtractor -> Cape, GhidraFeatureExtractor -> Ghidra, ... etc) -->
<span class="mx-2 text-white-alpha-30"> </span>
<span>CAPA v{{ data.meta.version }}</span>
<span class="mx-2 text-white-alpha-30"> </span>
<span>{{ new Date(data.meta.timestamp).toLocaleString() }}</span>
</div>
</div>
<!-- Vertical divider -->
<div class="mx-3 bg-white-alpha-30 hidden sm:block" style="width: 1px; height: 30px"></div>
<!-- Key metrics section -->
<div class="flex justify-content-around flex-grow-1">
<!-- Rules count -->
<div class="text-center">
<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">
<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
}
});
let 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;
// 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:
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>
@@ -0,0 +1,117 @@
<template>
<div ref="chartRef" class="w-screen h-screen"></div>
</template>
<script setup>
import { ref, onMounted } 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.4 },
marker: { line: { width: 2 } },
branchvalues: "total"
}
],
layout,
config
);
return sunburstData;
};
onMounted(() => {
renderChart();
});
</script>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref } from "vue";
import Menubar from "primevue/menubar";
const items = ref([
{
label: "Import Analysis",
icon: "pi pi-file-import",
command: () => (window.location.href = window.location.origin + "/capa/")
}
]);
</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="gitsub-icon" class="pi pi-github text-2xl"></i>
</a>
<img src="@/assets/images/icon.png" alt="Logo" class="w-2rem" />
</div>
</template>
</Menubar>
</template>
@@ -0,0 +1,221 @@
<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 font-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 "@/components/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) => {
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>
@@ -0,0 +1,377 @@
<template>
<TreeTable
:value="filteredTreeData"
v-model:expandedKeys="expandedKeys"
size="small"
scrollable
: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>
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters['global']" placeholder="Global search" />
</IconField>
</template>
<!-- Rule column -->
<Column
field="name"
header="Rule"
:sortable="true"
:expander="true"
filterMatchMode="contains"
style="width: 38%"
class="cursor-default"
>
<template #filter v-if="props.showColumnFilters">
<InputText
v-model="filters['name']"
type="text"
placeholder="Filter by rule or nested feature"
class="w-full"
/>
</template>
<template #body="{ node }">
<RuleColumn :node="node" />
</template>
</Column>
<!-- Address/Process column -->
<Column
field="address"
:header="props.data.meta.flavor === 'dynamic' ? 'Process' : 'Address'"
filterMatchMode="contains"
style="width: 8.5%"
class="cursor-default"
>
<template #filter v-if="props.showColumnFilters">
<InputText
v-model="filters['address']"
type="text"
:placeholder="`Filter by ${props.data.meta.flavor === 'dynamic' ? 'process' : 'address'}`"
class="w-full"
/>
</template>
<template #body="{ node }">
<span class="font-monospace text-sm"> {{ node.data.address }} </span>
</template>
</Column>
<!-- Namespace column -->
<Column
field="namespace"
header="Namespace"
sortable
filterMatchMode="contains"
style="width: 16%"
class="cursor-default"
>
<template #filter v-if="props.showColumnFilters">
<InputText v-model="filters['namespace']" type="text" placeholder="Filter by namespace" />
</template>
</Column>
<!-- Technique column -->
<Column
field="attack"
header="ATT&CK Technique"
sortable
:sortField="(node) => node?.attack[0]?.technique"
filterField="attack.0.parts"
filterMatchMode="contains"
style="width: 15%"
>
<template #filter v-if="props.showColumnFilters">
<InputText
v-model="filters['attack.0.parts']"
type="text"
placeholder="Filter by technique"
class="w-full"
/>
</template>
<template #body="{ node }">
<div class="flex flex-wrap">
<div v-for="(attack, index) in 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.split(".")[0] }})</span>
</a>
<div v-if="attack.subtechnique" style="font-size: 0.8em; margin-left: 2em">
<a :href="createATTACKHref(attack)" target="_blank">
{{ attack.subtechnique }}
<span class="text-500 text-xs font-normal ml-1">({{ attack.id }})</span>
</a>
</div>
</div>
</div>
</template>
</Column>
<!-- MBC column -->
<Column
field="mbc"
header="Malware Behavior Catalog"
sortable
:sortField="(node) => node?.mbc[0]?.parts[0]"
filterField="mbc.0.parts"
filterMatchMode="contains"
>
<template #filter v-if="props.showColumnFilters">
<InputText v-model="filters['mbc.0.parts']" type="text" placeholder="Filter by MBC" class="w-full" />
</template>
<template #body="{ node }">
<div class="flex flex-wrap">
<div v-for="(mbc, index) in 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>
</template>
</Column>
</TreeTable>
<!-- Right click context menu -->
<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 />
<!-- Source code dialog -->
<Dialog v-model:visible="sourceDialogVisible" style="width: 50vw">
<highlightjs autodetect :code="currentSource" />
</Dialog>
</template>
<script setup>
// Used to highlight function calls in dynamic mode
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 "@/components/columns/RuleColumn.vue";
import VTIcon from "@/components/misc/VTIcon.vue";
import { parseRules } from "@/utils/rdocParser";
import { createMBCHref, createATTACKHref, createCapaRulesUrl, createVirusTotalUrl } from "@/utils/urlHelpers";
const props = defineProps({
data: {
type: Object,
required: true
},
showLibraryRules: {
type: Boolean,
default: false
},
showColumnFilters: {
type: Boolean,
default: false
}
});
const treeData = ref([]);
// The `filters` ref in the setup section is used by PrimeVue to maintain the overall filter
// state of the table. Each column's filter contributes to this overall state.
const filters = ref({});
const filterMode = ref("lenient");
const sourceDialogVisible = ref(false);
const currentSource = ref("");
// expandedKeys keeps track of the nodes that are expanded
// for example, if a node with key "0" is expanded (and its first child is also expanded), expandedKeys will be { "0": true, "0-0": true }
// if the entire tree is collapsed expandedKeys will be {}
const expandedKeys = ref({});
// selectedNode is used as placeholder for the node that is right-clicked
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: createCapaRulesUrl(selectedNode.value, props.data.meta.version)
},
{
label: "Lookup rule in VirusTotal",
icon: "vt-icon",
target: "_blank",
url: createVirusTotalUrl(selectedNode.value.data?.name)
}
]);
const onRightClick = (event, instance) => {
if (instance.node.data.source) {
// We only enable right-click context menu on rows that have
// a source field (i.e. rules and `- match` features)
selectedNode.value = instance.node;
// show the context menu
console.log(menu);
menu.value.show(event);
}
};
/**
* Handles the expansion and collapse of nodes
*
* @param {Object} node - The selected node
*
* @example
* // Expanding a rule node
* onNodeSelect({
* key: '3',
* data: { type: 'rule', name: 'test rule', namespace: 'namespace', ... }
* children: [
* {
* key: '3-0',
* data: { type: 'match location', name: 'function @ 0x1000', namespace: null, ... }
* children: []
* }
* ]
* });
* // Result: expandedKeys.value = { '3': true, '3-0': true }
*/
const onNodeSelect = (node) => {
const nodeKey = node.key;
const nodeType = node.data.type;
// We only expand rule and match locations, otherwise 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 };
}
};
// 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);
}
});
/**
* Sets the source code of a node in the dialog.
*
* @param {string} source - The source code to be displayed.
*/
const showSource = (source) => {
currentSource.value = source;
sourceDialogVisible.value = true;
};
onMounted(() => {
const cacheKey = "ruleMatches";
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
// If cached data exists, parse and use it
treeData.value = JSON.parse(cachedData);
} else {
// If no cached data, parse the rules and store in sessionStorage
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout);
// Store the parsed data in sessionStorage
try {
sessionStorage.setItem(cacheKey, JSON.stringify(treeData.value));
} catch (e) {
console.warn("Failed to store parsed data in sessionStorage:", e);
// If storing fails (e.g., due to storage limits), we can still continue with the parsed 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>
@@ -0,0 +1,90 @@
<template>
<Card>
<template #content>
<div class="flex align-items-center flex-wrap 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 class="flex flex-row align-items-center gap-2">
<Checkbox
v-model="showColumnFilters"
inputId="showColumnFilters"
:binary="true"
:disabled="showNamespaceChart"
/>
<label for="showColumnFilters"> Show column filters </label>
</div>
</div>
</template>
</Card>
</template>
<script setup>
import { ref, 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 showColumnFilters = ref(false);
const emit = defineEmits([
"update:show-capabilities-by-function-or-process",
"update:show-library-rules",
"update:show-namespace-chart",
"update:show-column-filters"
]);
const capabilitiesLabel = 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);
});
watch(showColumnFilters, (newValue) => {
emit("update:show-column-filters", newValue);
});
</script>
@@ -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>
@@ -0,0 +1,67 @@
<template>
<div class="cursor-default">
<!--- example node: "parse PE headers (2 matches) lib" --->
<template v-if="node.data.type === 'rule'">
<div>
<span>{{ node.data.name }}</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>
<!--- example node: "basic block @ 0x401000" or "explorer.exe" --->
<template v-else-if="node.data.type === 'match location'">
<span class="text-sm font-italic">{{ node.data.name }}</span>
</template>
<!--- example node: "- or", "- and" --->
<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>
<!--- example node: "- api: GetProcAddress", "- regex: .*\\.exe" --->
<template v-else-if="node.data.type === 'feature'">
<span>
- {{ node.data.typeValue }}:
<span :class="{ 'text-green-700': node.data.typeValue !== 'regex' }" class="font-monospace">
{{ node.data.name }}
</span>
</span>
</template>
<!--- example node: "- malware.exe" (these are the captures (i.e. children nodes) of regex nodes) --->
<template v-else-if="node.data.type === 'regex-capture'">
- <span class="text-green-700 font-monospace">{{ node.data.name }}</span>
</template>
<!--- example node: "exit(0) -> 0" (if the node type is call-info, we highlight node.data.name.callInfo) --->
<template v-else-if="node.data.type === 'call-info'">
<highlightjs lang="c" :code="node.data.name.callInfo" />
</template>
<!-- example node: " = IMAGE_NT_SIGNATURE (PE)" --->
<span v-if="node.data.description" class="text-gray-500 text-sm" style="font-size: 90%">
= {{ node.data.description }}
</span>
</div>
</template>
<script setup>
import { defineProps } from "vue";
import LibraryTag from "@/components/misc/LibraryTag.vue";
defineProps({
node: {
type: Object,
required: true
}
});
</script>
@@ -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>
@@ -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>
@@ -0,0 +1,88 @@
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
View File
@@ -0,0 +1,88 @@
import "primeicons/primeicons.css";
import "./assets/main.css";
import "highlight.js/styles/default.css";
import "primeflex/primeflex.css";
import "primeflex/themes/primeone-light.css";
import "highlight.js/lib/common";
import hljsVuePlugin from "@highlightjs/vue-plugin";
import { createApp } from "vue";
import PrimeVue from "primevue/config";
import Ripple from "primevue/ripple";
import Aura from "@primevue/themes/aura";
import App from "./App.vue";
import MenuBar from "primevue/menubar";
import Card from "primevue/card";
import Panel from "primevue/panel";
import Column from "primevue/column";
import Checkbox from "primevue/checkbox";
import FloatLabel from "primevue/floatlabel";
import Tooltip from "primevue/tooltip";
import Divider from "primevue/divider";
import ContextMenu from "primevue/contextmenu";
import ToastService from "primevue/toastservice";
import Toast from "primevue/toast";
import router from "./router";
import { definePreset } from "@primevue/themes";
const Noir = definePreset(Aura, {
semantic: {
primary: {
50: "{zinc.50}",
100: "{zinc.100}",
200: "{zinc.200}",
300: "{zinc.300}",
400: "{zinc.400}",
500: "{zinc.500}",
600: "{zinc.600}",
700: "{zinc.700}",
800: "{zinc.800}",
900: "{zinc.900}",
950: "{zinc.950}"
},
colorScheme: {
light: {
primary: {
color: "{slate.800}",
inverseColor: "#ffffff",
hoverColor: "{zinc.900}",
activeColor: "{zinc.800}"
}
}
}
}
});
const app = createApp(App);
app.use(router);
app.use(hljsVuePlugin);
app.use(PrimeVue, {
theme: {
preset: Noir,
options: {
darkModeSelector: "light"
}
},
ripple: true
});
app.use(ToastService);
app.directive("tooltip", Tooltip);
app.directive("ripple", Ripple);
app.component("Card", Card);
app.component("Divider", Divider);
app.component("Toast", Toast);
app.component("Panel", Panel);
app.component("MenuBar", MenuBar);
app.component("Checkbox", Checkbox);
app.component("FloatLabel", FloatLabel);
app.component("Column", Column);
app.component("ContextMenu", ContextMenu);
app.mount("#app");
+22
View File
@@ -0,0 +1,22 @@
import { createRouter, createWebHashHistory } from "vue-router";
import ImportView from "../views/ImportView.vue";
import NotFoundView from "../views/NotFoundView.vue";
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: ImportView
},
// 404 Route - This should be the last route
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: NotFoundView
}
]
});
export default router;
+286
View File
@@ -0,0 +1,286 @@
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: {
feature_counts: {
file: 0,
functions: []
},
layout: {
functions: []
}
}
},
rules: {}
};
const result = parseFunctionCapabilities(mockData, false);
expect(result).toEqual([]);
});
it("should parse a single function with one rule match", () => {
const mockDoc = {
meta: {
analysis: {
layout: {
functions: [
{
address: { type: "absolute", value: 0x1000 },
matched_basic_blocks: [{ address: { type: "absolute", value: 0x1000 } }]
}
]
},
feature_counts: {
functions: [{ address: { type: "absolute", value: 0x1000 } }]
}
}
},
rules: {
rule1: {
meta: {
name: "Test Rule",
namespace: "test",
lib: false,
scopes: { static: "function" }
},
matches: [[{ type: "absolute", value: 0x1000 }]]
}
}
};
const result = parseFunctionCapabilities(mockDoc);
expect(result).toEqual([
{
address: "0x1000",
capabilities: [{ name: "Test Rule", namespace: "test", lib: false }]
}
]);
});
it("should handle multiple rules matching a single function", () => {
const mockDoc = {
meta: {
analysis: {
layout: {
functions: [
{
address: { type: "absolute", value: 0x1000 },
matched_basic_blocks: [{ address: { type: "absolute", value: 0x1000 } }]
}
]
},
feature_counts: {
functions: [{ address: { type: "absolute", value: 0x1000 } }]
}
}
},
rules: {
rule1: {
meta: {
name: "Test Rule 1",
lib: true,
scopes: { static: "function" }
},
matches: [[{ type: "absolute", value: 0x1000 }]]
},
rule2: {
meta: {
name: "Test Rule 2",
namespace: "test",
lib: false,
scopes: { static: "function" }
},
matches: [[{ type: "absolute", value: 0x1000 }]]
}
}
};
const result = parseFunctionCapabilities(mockDoc);
expect(result).toEqual([
{
address: "0x1000",
capabilities: [
{ name: "Test Rule 1", lib: true },
{ name: "Test Rule 2", namespace: "test", lib: false }
]
}
]);
});
it("should handle basic block scoped rules", () => {
const mockDoc = {
meta: {
analysis: {
layout: {
functions: [
{
address: { type: "absolute", value: 0x1000 },
matched_basic_blocks: [{ address: { type: "absolute", value: 0x1100 } }]
}
]
},
feature_counts: {
functions: [{ address: { type: "absolute", value: 0x1000 } }]
}
}
},
rules: {
rule1: {
meta: {
name: "Basic Block Rule",
namespace: "test",
lib: false,
scopes: { static: "basic block" }
},
matches: [[{ type: "absolute", value: 0x1100 }]]
}
}
};
const result = parseFunctionCapabilities(mockDoc);
expect(result).toEqual([
{
address: "0x1000",
capabilities: [{ name: "Basic Block Rule", namespace: "test", lib: false }]
}
]);
});
it("should handle a single rule matching in multiple functions", () => {
const mockDoc = {
meta: {
analysis: {
layout: {
functions: [
{
address: { type: "absolute", value: 0x1000 },
matched_basic_blocks: [{ address: { type: "absolute", value: 0x1000 } }]
},
{
address: { type: "absolute", value: 0x2000 },
matched_basic_blocks: [{ address: { type: "absolute", value: 0x2000 } }]
}
]
},
feature_counts: {
functions: [
{ address: { type: "absolute", value: 0x1000 } },
{ address: { type: "absolute", value: 0x2000 } }
]
}
}
},
rules: {
rule1: {
meta: {
name: "Test Rule",
namespace: "test",
lib: false,
scopes: { static: "function" }
},
matches: [[{ type: "absolute", value: 0x1000 }], [{ type: "absolute", value: 0x2000 }]]
}
}
};
const result = parseFunctionCapabilities(mockDoc);
expect(result).toEqual([
{
address: "0x1000",
capabilities: [{ name: "Test Rule", namespace: "test", lib: false }]
},
{
address: "0x2000",
capabilities: [{ name: "Test Rule", namespace: "test", lib: false }]
}
]);
});
});
+38
View File
@@ -0,0 +1,38 @@
import pako from "pako";
/**
* Checks if the given file is gzipped
* @param {File} file - The file to check
* @returns {Promise<boolean>} - True if the file is gzipped, false otherwise
*/
export const isGzipped = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return uint8Array[0] === 0x1f && uint8Array[1] === 0x8b;
};
/**
* Decompresses a gzipped file
* @param {File} file - The gzipped file to decompress
* @returns {Promise<string>} - The decompressed file content as a string
*/
export const decompressGzip = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const decompressed = pako.inflate(uint8Array, { to: "string" });
return decompressed;
};
/**
* Reads a file as text
* @param {File} file - The file to read
* @returns {Promise<string>} - The file content as a string
*/
export const readFileAsText = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = (error) => reject(error);
reader.readAsText(file);
});
};
+610
View File
@@ -0,0 +1,610 @@
/**
* 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=1] - 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,
attack: rule.meta.attack
}
};
// 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;
// Is this a static rule with a file-level scope?
const isFileScope = rule.meta.scopes && rule.meta.scopes.static === "file";
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;
});
}
// Finally, add a note if there are more matches than the limit (only applicable in dynamic mode)
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 the capabilities of functions from a given rdoc.
*
* @param {Object} doc - The document containing function and rule information.
* @returns {Array} An array of objects, each representing a function with its address and capabilities.
*
* @example
* [
* {
* "address": "0x14002A690",
* "capabilities": [
* {
* "name": "contain loop",
* "lib": true
*
* },
* {
* "name": "get disk information",
* "namespace": "host-interaction/hardware/storage"
* "lib": false
* }
* ]
* }
* ]
*/
export function parseFunctionCapabilities(doc) {
// Map basic blocks to their their parent functions
const functionsByBB = new Map();
for (const finfo of doc.meta.analysis.layout.functions) {
const faddress = finfo.address;
for (const bb of finfo.matched_basic_blocks) {
const bbaddress = bb.address;
functionsByBB.set(formatAddress(bbaddress), formatAddress(faddress));
}
}
// Map to store capabilities matched to each function
const matchesByFunction = new Map();
// Iterate through all rules in the document
for (const [, rule] of Object.entries(doc.rules)) {
if (rule.meta.scopes.static === "function") {
for (const [address] of rule.matches) {
const addr = formatAddress(address);
if (!matchesByFunction.has(addr)) {
matchesByFunction.set(addr, new Set());
}
matchesByFunction
.get(addr)
.add({ name: rule.meta.name, namespace: rule.meta.namespace, lib: rule.meta.lib });
}
} else if (rule.meta.scopes.static === "basic block") {
for (const [address] of rule.matches) {
const addr = formatAddress(address);
const function_ = functionsByBB.get(addr);
if (function_) {
if (!matchesByFunction.has(function_)) {
matchesByFunction.set(function_, new Set());
}
matchesByFunction
.get(function_)
.add({ name: rule.meta.name, namespace: rule.meta.namespace, lib: rule.meta.lib });
}
}
}
// (else) Ignoring file scope rules
}
const result = [];
// Iterate through all functions in the document
for (const f of doc.meta.analysis.feature_counts.functions) {
const addr = formatAddress(f.address);
const matches = matchesByFunction.get(addr);
// Skip functions with no matches (unlikely)
if (!matches || matches.size === 0) continue;
// Add function to result with its address and sorted capabilities
result.push({
address: addr,
capabilities: Array.from(matches)
});
}
return result;
}
// 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?.type || processedNode.node.feature?.type, // e.g., number, regex, api, or, and, optional ... etc
success: processedNode.success,
name: getNodeName(processedNode),
lib: lib,
address: getNodeAddress(processedNode),
description: getNodeDescription(processedNode)
},
children: []
};
// Recursively parse node children (i.e., nested statements or features)
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;
}
// regex features have captures, which we need to process and add as children
if (processedNode.node.feature && processedNode.node.feature.type === "regex") {
result.children = processRegexCaptures(processedNode, key);
}
// Add call information for dynamic sandbox traces when the feature is `api`
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;
}
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()}`;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 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 first character
if (["B", "T", "E", "F"].includes(mbc.id[0])) {
// 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 {
// unknown
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;
}
}
/**
* Creates a CAPA rules URL for a given node with tag.
*
* @param {Object} node - The node object containing data about the rule.
* @param {string} node.data.namespace - The namespace of the rule (optional).
* @param {string} node.data.name - The name of the rule.
* @returns {string} The formatted CAPA rules URL.
*/
export function createCapaRulesUrl(node, tag) {
if (!node || !node.data || !tag) return null;
const namespace = node.data.namespace || "lib";
const ruleName = node.data.name.toLowerCase().replace(/\s+/g, "-");
return `https://github.com/mandiant/capa-rules/blob/v${tag}/${namespace}/${ruleName}.yml`;
}
/**
* Creates a VirusTotal deep link URL for a given behavior signature.
*
* @param {string} behaviorName - The name of the behavior signature.
* @returns {string} The formatted VirusTotal URL.
*/
export function createVirusTotalUrl(behaviorName) {
const behaviourSignature = `behaviour_signature:"${behaviorName}"`;
return `https://www.virustotal.com/gui/search/${encodeURIComponent(behaviourSignature)}/files`;
}
+130
View File
@@ -0,0 +1,130 @@
<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 "@testfiles/rd/al-khaser_x64.exe_.json";
import demoRdocDynamic from "@testfiles/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 showColumnFilters = ref(false);
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 updateShowColumnFilters = (value) => {
showColumnFilters.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(() => {
// Clear out sessionStorage to prevent stale data from being used
sessionStorage.clear();
// Check if the URL contains a rdoc parameter and load the data from that URL
const urlParams = new URLSearchParams(window.location.search);
const encodedRdocURL = urlParams.get("rdoc");
if (encodedRdocURL) {
const rdocURL = decodeURIComponent(encodedRdocURL);
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="rdocData.meta.flavor"
:library-rule-matches-count="libraryRuleMatchesCount"
@update:show-capabilities-by-function-or-process="updateShowCapabilitiesByFunctionOrProcess"
@update:show-library-rules="updateShowLibraryRules"
@update:show-namespace-chart="updateShowNamespaceChart"
@update:show-column-filters="updateShowColumnFilters"
/>
<RuleMatchesTable
v-if="!showCapabilitiesByFunctionOrProcess && !showNamespaceChart"
:data="rdocData"
:show-library-rules="showLibraryRules"
:show-column-filters="showColumnFilters"
/>
<FunctionCapabilities
v-if="rdocData.meta.flavor === 'static' && showCapabilitiesByFunctionOrProcess && !showNamespaceChart"
:data="rdocData"
:show-library-rules="showLibraryRules"
/>
<ProcessCapabilities
v-else-if="rdocData.meta.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
View File
@@ -0,0 +1,19 @@
<template>
<div class="flex flex-column align-items-center justify-content-center min-h-screen bg-blue-50">
<h1 class="text-900 font-bold text-8xl mb-4">404</h1>
<p class="text-600 text-3xl mb-5">Oops! The page you're looking for doesn't exist.</p>
<Button label="Go Home" icon="pi pi-home" @click="goHome" />
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import Button from "primevue/button";
const router = useRouter();
const goHome = () => {
router.push("/");
};
</script>
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteSingleFile } from "vite-plugin-singlefile";
import { fileURLToPath, URL } from "node:url";
// 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()],
resolve: {
alias: {
"@": fileURLToPath(new URL("src", import.meta.url)),
"@testfiles": fileURLToPath(new URL("../../tests/data", import.meta.url))
}
}
};
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: "jsdom",
exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"]
}
});