mirror of
https://github.com/mandiant/capa.git
synced 2026-02-04 11:07:53 -08:00
342 lines
11 KiB
Vue
342 lines
11 KiB
Vue
<template>
|
|
<div class="card">
|
|
<TreeTable
|
|
:value="filteredTreeData"
|
|
v-model:expandedKeys="expandedKeys"
|
|
size="small"
|
|
:filters="filters"
|
|
:filterMode="filterMode.value"
|
|
sortField="namespace"
|
|
:sortOrder="-1"
|
|
removableSort
|
|
:rowHover="true"
|
|
:indentation="1.3"
|
|
selectionMode="single"
|
|
@node-select="onNodeSelect"
|
|
:pt="{
|
|
row: ({ instance }) => ({
|
|
oncontextmenu: (event) => onRightClick(event, instance)
|
|
})
|
|
}"
|
|
>
|
|
<template #header>
|
|
<div class="flex justify-content-end w-full">
|
|
<IconField>
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText v-model="filters['global']" placeholder="Global search" />
|
|
</IconField>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Rule column (always visible) -->
|
|
<Column field="name" header="Rule" :sortable="true" :expander="true" filterMatchMode="contains">
|
|
<template #filter>
|
|
<InputText v-model="filters['name']" type="text" placeholder="Filter by Rule or Feature" />
|
|
</template>
|
|
<template #body="{ node }">
|
|
<RuleColumn :node="node" />
|
|
</template>
|
|
</Column>
|
|
|
|
<Column
|
|
v-for="col in visibleColumns"
|
|
:key="col.field"
|
|
:field="col.field"
|
|
:header="props.data.meta.flavor === 'dynamic' && col.field === 'address' ? 'Process' : col.header"
|
|
:sortable="col.field !== 'source'"
|
|
:class="{ 'w-3': col.field === 'mbc', 'w-full': col.field === 'name' }"
|
|
filterMatchMode="contains"
|
|
>
|
|
<!-- Filter template -->
|
|
<template #filter>
|
|
<InputText v-model="filters[col.field]" type="text" :placeholder="`Filter by ${col.header}`" />
|
|
</template>
|
|
|
|
<!-- Address column body template -->
|
|
<template v-if="col.field === 'address'" #body="slotProps">
|
|
<span class="text-sm" style="font-family: monospace">
|
|
{{ slotProps.node.data.address }}
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Tactic column body template -->
|
|
<template v-if="col.field === 'tactic'" #body="slotProps">
|
|
<div v-if="slotProps.node.data.attack">
|
|
<div v-for="(attack, index) in slotProps.node.data.attack" :key="index">
|
|
<a :href="createATTACKHref(attack)" target="_blank">
|
|
{{ attack.technique }} <span class="text-500 text-sm font-normal ml-1">({{ attack.id }})</span>
|
|
</a>
|
|
<div
|
|
v-for="(technique, techIndex) in attack.techniques"
|
|
:key="techIndex"
|
|
style="font-size: 0.8em; margin-left: 1em"
|
|
>
|
|
<a :href="createATTACKHref(technique)" target="_blank">
|
|
↳ {{ technique.technique }}
|
|
<span class="text-500 text-xs font-normal ml-1">({{ technique.id }})</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- MBC column body template -->
|
|
<template v-if="col.field === 'mbc'" #body="slotProps">
|
|
<div v-if="slotProps.node.data.mbc">
|
|
<div v-for="(mbc, index) in slotProps.node.data.mbc" :key="index">
|
|
<a :href="createMBCHref(mbc)" target="_blank">
|
|
{{ mbc.parts.join('::') }}
|
|
<span class="text-500 text-sm font-normal opacity-80 ml-1">[{{ mbc.id }}]</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Namespace column body template -->
|
|
<template v-if="col.field === 'namespace'" #body="slotProps">
|
|
<span v-if="!slotProps.node.data.lib">
|
|
{{ slotProps.node.data.namespace }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
</TreeTable>
|
|
<ContextMenu ref="menu" :model="contextMenuItems">
|
|
<template #item="{ item, props }">
|
|
<a v-ripple v-bind="props.action" :href="item.url" :target="item.target">
|
|
<span v-if="item.icon !== 'vt-icon'" :class="item.icon" />
|
|
<VTIcon v-else-if="item.icon === 'vt-icon'" />
|
|
<span>{{ item.label }}</span>
|
|
</a>
|
|
</template>
|
|
</ContextMenu>
|
|
|
|
<Toast />
|
|
|
|
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
|
|
<highlightjs autodetect :code="currentSource" />
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import 'highlight.js/styles/stackoverflow-light.css'
|
|
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import TreeTable from 'primevue/treetable'
|
|
import InputText from 'primevue/inputtext'
|
|
import Dialog from 'primevue/dialog'
|
|
import Column from 'primevue/column'
|
|
import IconField from 'primevue/iconfield'
|
|
import InputIcon from 'primevue/inputicon'
|
|
import ContextMenu from 'primevue/contextmenu'
|
|
|
|
import RuleColumn from './columns/RuleColumn.vue'
|
|
import VTIcon from './misc/VTIcon.vue'
|
|
|
|
import { parseRules } from '../utils/rdocParser'
|
|
|
|
const props = defineProps({
|
|
data: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
showLibraryRules: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const treeData = ref([])
|
|
const filters = ref({})
|
|
const filterMode = ref({ value: 'lenient' })
|
|
const sourceDialogVisible = ref(false)
|
|
const currentSource = ref('')
|
|
const expandedKeys = ref({})
|
|
|
|
const menu = ref()
|
|
const selectedNode = ref({})
|
|
|
|
const contextMenuItems = computed(() => [
|
|
{
|
|
label: 'View source',
|
|
icon: 'pi pi-eye',
|
|
command: () => {
|
|
showSource(selectedNode.value.data.source)
|
|
}
|
|
},
|
|
{
|
|
label: 'View rule in capa-rules',
|
|
icon: 'pi pi-external-link',
|
|
target: '_blank',
|
|
url: selectedNode.value.url
|
|
},
|
|
{
|
|
label: 'Lookup rule in VirusTotal',
|
|
icon: 'vt-icon',
|
|
target: '_blank',
|
|
url: selectedNode.value.vturl
|
|
}
|
|
])
|
|
|
|
const onRightClick = (event, instance) => {
|
|
if (instance.node.data.source) {
|
|
selectedNode.value = instance.node
|
|
// contrust capa-rules url
|
|
selectedNode.value.url = `https://github.com/mandiant/capa-rules/blob/master/${instance.node.data.namespace || 'lib'}/${instance.node.data.name.toLowerCase().replace(/\s+/g, '-')}.yml`
|
|
// construct VirusTotal deep link
|
|
const behaviourSignature = `behaviour_signature:"${instance.node.data.name}"`
|
|
selectedNode.value.vturl = `https://www.virustotal.com/gui/search/${behaviourSignature}/files`
|
|
|
|
menu.value.show(event)
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Expand node on click
|
|
*/
|
|
const onNodeSelect = (node) => {
|
|
const nodeKey = node.key
|
|
const nodeType = node.data.type
|
|
|
|
// We only expand rule and match locations, if not return
|
|
if (nodeType !== 'rule' && nodeType !== 'match location') return
|
|
|
|
// If the node is already expanded, collapse it
|
|
if (expandedKeys.value[nodeKey]) {
|
|
delete expandedKeys.value[nodeKey]
|
|
return
|
|
}
|
|
|
|
if (nodeType === 'rule') {
|
|
// For rule nodes, clear existing expanded keys and set the clicked rule as expanded
|
|
// and expand the first (child) match by default
|
|
expandedKeys.value = { [nodeKey]: true, [`${nodeKey}-0`]: true }
|
|
} else if (nodeType === 'match location') {
|
|
// For match location nodes, we need to keep the parent expanded
|
|
// and toggle the clicked node while collapsing siblings
|
|
const [parentKey] = nodeKey.split('-')
|
|
expandedKeys.value = { [parentKey]: true, [`${nodeKey}`]: true }
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
// All available columns
|
|
const visibleColumns = ref([
|
|
{ field: 'address', header: 'Address' },
|
|
{ field: 'namespace', header: 'Namespace' },
|
|
{ field: 'tactic', header: 'ATT&CK Tactic' },
|
|
{ field: 'mbc', header: 'Malware Behaviour Catalogue' }
|
|
])
|
|
|
|
// Filter out the treeData for showing/hiding lib rules
|
|
const filteredTreeData = computed(() => {
|
|
if (props.showLibraryRules) {
|
|
return treeData.value // Return all data when showLibraryRules is true
|
|
} else {
|
|
// Filter out library rules when showLibraryRules is false
|
|
const filterNode = (node) => {
|
|
if (node.data && node.data.lib) {
|
|
return false
|
|
}
|
|
if (node.children) {
|
|
node.children = node.children.filter(filterNode)
|
|
}
|
|
return true
|
|
}
|
|
return treeData.value.filter(filterNode)
|
|
}
|
|
})
|
|
|
|
const showSource = (source) => {
|
|
currentSource.value = source
|
|
sourceDialogVisible.value = true
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.data && props.data.rules) {
|
|
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout)
|
|
} else {
|
|
console.error('Invalid data prop:', props.data)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Creates an MBC (Malware Behavior Catalog) URL from an MBC object.
|
|
*
|
|
* @param {Object} mbc - The MBC object to format.
|
|
* @returns {string} The MBC URL.
|
|
*/
|
|
|
|
function createMBCHref(mbc) {
|
|
let baseUrl
|
|
|
|
// Determine the base URL based on the id
|
|
if (mbc.id.startsWith('B')) {
|
|
// Behavior
|
|
baseUrl = 'https://github.com/MBCProject/mbc-markdown/blob/main'
|
|
} else if (mbc.id.startsWith('C')) {
|
|
// Micro-Behavior
|
|
baseUrl = 'https://github.com/MBCProject/mbc-markdown/blob/main/micro-behaviors'
|
|
} else {
|
|
return null
|
|
}
|
|
|
|
// Convert the objective and behavior to lowercase and replace spaces with hyphens
|
|
const objectivePath = mbc.objective.toLowerCase().replace(/\s+/g, '-')
|
|
const behaviorPath = mbc.behavior.toLowerCase().replace(/\s+/g, '-')
|
|
|
|
// Construct the final URL
|
|
return `${baseUrl}/${objectivePath}/${behaviorPath}.md`
|
|
}
|
|
|
|
/**
|
|
* Creates a MITRE ATT&CK URL for a specific technique or sub-technique.
|
|
*
|
|
* @param {Object} attack - The ATT&CK object containing information about the technique.
|
|
* @param {string} attack.id - The ID of the ATT&CK technique or sub-technique.
|
|
* @returns {string} The formatted MITRE ATT&CK URL for the technique.
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
</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>
|