mirror of
https://github.com/mandiant/capa.git
synced 2026-06-22 14:51:56 -07:00
Merge branch 'master' into vmray-extractor
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+1
-1
Submodule tests/data updated: 5c3b7a8da4...245d1dbfed
@@ -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"
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+4133
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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 }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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