mirror of
https://github.com/mandiant/capa.git
synced 2025-12-12 07:40:38 -08:00
364 lines
12 KiB
Vue
364 lines
12 KiB
Vue
<template>
|
|
<TreeTable
|
|
:value="filteredTreeData"
|
|
v-model:expandedKeys="expandedKeys"
|
|
size="small"
|
|
:scrollable="true"
|
|
: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 column (only shown for static flavor) -->
|
|
<Column
|
|
v-if="props.data.meta.flavor === 'static'"
|
|
field="address"
|
|
header="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>
|
|
<i v-if="item.description" class="pi pi-info-circle text-xs" v-tooltip.right="item.description" />
|
|
</a>
|
|
</template>
|
|
</ContextMenu>
|
|
|
|
<!-- Source code dialog -->
|
|
<Dialog v-model:visible="sourceDialogVisible" style="width: 50vw">
|
|
<highlightjs :autodetect="false" language="yaml" :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: "Copy rule name",
|
|
icon: "pi pi-copy",
|
|
command: () => {
|
|
navigator.clipboard.writeText(selectedNode.value.data?.name);
|
|
}
|
|
},
|
|
{
|
|
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)
|
|
},
|
|
{
|
|
label: "Lookup rule in VirusTotal",
|
|
icon: "vt-icon",
|
|
target: "_blank",
|
|
description: "Requires VirusTotal Premium account",
|
|
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
|
|
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(() => {
|
|
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout);
|
|
});
|
|
</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;
|
|
}
|
|
|
|
/* 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>
|