feature: add call information to api feature in dynamic mode (-vv)

This commit is contained in:
Soufiane Fariss
2024-07-30 16:24:05 +02:00
parent 13261d0c41
commit e70e1b0641
3 changed files with 294 additions and 127 deletions

View File

@@ -1,11 +1,11 @@
<template>
<div class="card">
<TreeTable
:value="filteredTreeData"
:value="filteredTreeData"
v-model:expandedKeys="expandedKeys"
size="small"
:filters="filters"
:filterMode="filterMode.value"
:filterMode="filterMode.value"
sortField="namespace"
:sortOrder="-1"
removableSort
@@ -16,18 +16,12 @@
@nodeSelect="onNodeSelect"
:pt="{
row: ({ instance }) => ({
oncontextmenu: (event) => onRightClick(event, instance),
}),
oncontextmenu: (event) => onRightClick(event, instance)
})
}"
>
<template #header>
<div
style="
display: flex;
justify-content: end;
align-items: center;
"
>
<div style="display: flex; justify-content: end; align-items: center">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters['global']" placeholder="Global search" />
@@ -36,19 +30,9 @@
</template>
<!-- Name column (always visible) -->
<Column
field="name"
header="Rule"
:sortable="true"
:expander="true"
filterMatchMode="contains"
>
<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"
/>
<InputText v-model="filters['name']" type="text" placeholder="Filter by Rule or Feature" />
</template>
<template #body="{ node }">
<RuleColumn :node="node" />
@@ -59,26 +43,20 @@
v-for="col in visibleColumns"
:key="col.field"
:field="col.field"
:header="
props.data.meta.flavor === 'dynamic' && col.field === 'address' ? 'Process' : col.header
"
: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}`"
/>
<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 style="font-family: monospace">
{{ slotProps.node.data.type === 'match location' ? "" : slotProps.node.data.address}}
{{ slotProps.node.data.type === 'match location' ? '' : slotProps.node.data.address }}
</span>
</template>
@@ -95,7 +73,8 @@
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>
{{ technique.technique }}
<span class="text-500 text-xs font-normal ml-1">({{ technique.id }})</span>
</a>
</div>
</div>
@@ -107,8 +86,9 @@
<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>
{{ mbc.parts.join('::') }}
<span class="text-500 text-sm font-normal opacity-80 ml-1">[{{ mbc.id }}]</span>
</a>
</div>
</div>
</template>
@@ -123,15 +103,15 @@
</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>
<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/>
<Toast />
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
<highlightjs autodetect :code="currentSource" />
@@ -149,10 +129,8 @@ 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 RuleColumn from './columns/RuleColumn.vue'
import VTIcon from './misc/VTIcon.vue'
import { parseRules } from '../utils/rdocParser'
@@ -174,8 +152,8 @@ const sourceDialogVisible = ref(false)
const currentSource = ref('')
const expandedKeys = ref({})
const menu = ref();
const selectedNode = ref({});
const menu = ref()
const selectedNode = ref({})
const contextMenuItems = computed(() => [
{
@@ -183,55 +161,55 @@ const contextMenuItems = computed(() => [
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,
url: selectedNode.value.url
},
{
label: 'Lookup rule in VirusTotal',
icon: 'vt-icon',
target: '_blank',
url: selectedNode.value.vturl,
},
]);
url: selectedNode.value.vturl
}
])
const onRightClick = (event, instance) => {
if (instance.node.data.source) {
selectedNode.value = instance.node;
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}"`;
const behaviourSignature = `behaviour_signature:"${instance.node.data.name}"`
selectedNode.value.vturl = `https://www.virustotal.com/gui/search/${behaviourSignature}/files`
menu.value.show(event);
menu.value.show(event)
}
};
}
/*
* Expand node on click
/*
* Expand node on click
*/
const onNodeSelect = (node) => {
const nodeKey = node.key;
const nodeType = node.data.type;
const nodeKey = node.key
const nodeType = node.data.type
if (nodeType === 'rule') {
// For rule nodes, clear existing expanded keys and set the clicked rule as expanded
// expand the first (child) match by default
expandedKeys.value = { [nodeKey]: true, [`${nodeKey}-0`]: true };
// 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};
const [parentKey, _] = nodeKey.split('-')
expandedKeys.value = { [parentKey]: true, [`${nodeKey}`]: true }
} else {
return
}
};
}
// All available columns
const togglableColumns = ref([
@@ -272,10 +250,9 @@ const showSource = (source) => {
sourceDialogVisible.value = true
}
onMounted(() => {
if (props.data && props.data.rules) {
treeData.value = parseRules(props.data.rules, props.data.meta.flavor)
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout)
} else {
console.error('Invalid data prop:', props.data)
}
@@ -289,25 +266,25 @@ onMounted(() => {
*/
function createMBCHref(mbc) {
let baseUrl;
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';
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';
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, '-');
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`;
return `${baseUrl}/${objectivePath}/${behaviorPath}.md`
}
/**
@@ -317,33 +294,34 @@ function createMBCHref(mbc) {
* @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('.');
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]}`;
return `${baseUrl}${idParts[0]}`
} else if (idParts.length === 2) {
// It's a sub-technique
return `${baseUrl}${idParts[0]}/${idParts[1]}`;
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;
: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;
visibility: collapse !important;
height: 1.3rem;
}
/* Make all matches nodes (i.e. not rule names) slightly smaller,

View File

@@ -1,40 +1,50 @@
<template>
<div>
<template v-if="node.data.type === 'rule'">
<div>
<template v-if="node.data.type === 'rule'">
{{ node.data.name }}
</template>
<template v-else-if="node.data.type === 'match location'">
<span class="text-sm font-italic">{{ node.data.name }}</span>
</template>
<template v-else-if="node.data.type === 'statement'"
>-
<span
:class="{ 'text-green-700': node.data.typeValue === 'range', 'font-semibold': node.data.typeValue !== 'range' }"
>
{{ node.data.name }}
</template>
<template v-else-if="node.data.type === 'match location'">
<span class="text-sm font-italic">{{ node.data.name }}</span>
</template>
<template v-else-if="node.data.type === 'statement'">-
<span :class="{ 'text-green-700': node.data.typeValue === 'range', 'font-semibold': node.data.typeValue !== 'range' }">
{{ node.data.name }}
</span>
</template>
<template v-else-if="node.data.type === 'feature'">
<span>- {{ node.data.typeValue }}: <span :class="{'text-green-700': node.data.typeValue !== 'regex'}" style="font-family: monospace;">{{ node.data.name }}</span></span>
</template>
<template v-else-if="node.data.type === 'regex-capture'">
- <span class="text-green-700" style="font-family: monospace;">{{ node.data.name }}</span>
</template>
<span v-if="node.data.description" class="text-gray-500 text-sm" style="font-size: 90%;">
= {{ node.data.description }}
</span>
<span v-if="node.data.matchCount > 1" class="font-italic">
({{ node.data.matchCount }} matches)
</span>
<LibraryTag v-if="node.data.lib && node.data.matchCount" />
</div>
</template>
</template>
<template v-else-if="node.data.type === 'feature'">
<span
>- {{ node.data.typeValue }}:
<span :class="{ 'text-green-700': node.data.typeValue !== 'regex' }" style="font-family: monospace">{{
node.data.name
}}</span></span
>
</template>
<template v-else-if="node.data.type === 'regex-capture'">
- <span class="text-green-700" style="font-family: monospace">{{ node.data.name }}</span>
</template>
<template v-else-if="node.data.type === 'call-info'">
<!-- <code class="text-gray-700">{{ node.data.name }}</code> -->
<highlightjs lang="c" :code="node.data.name" />
</template>
<span v-if="node.data.description" class="text-gray-500 text-sm" style="font-size: 90%">
= {{ node.data.description }}
</span>
<span v-if="node.data.matchCount > 1" class="font-italic"> ({{ node.data.matchCount }} matches) </span>
<LibraryTag v-if="node.data.lib && node.data.matchCount" />
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import LibraryTag from '../misc/LibraryTag.vue';
<script setup>
import { defineProps } from 'vue'
import LibraryTag from '../misc/LibraryTag.vue'
defineProps({
node: {
type: Object,
required: true
}
});
</script>
defineProps({
node: {
type: Object,
required: true
}
})
</script>

View File

@@ -3,7 +3,7 @@
* @param {Object} rules - The rules object from the rodc JSON data
* @returns {Array} - Parsed tree data for the TreeTable component
*/
export function parseRules(rules, flavor) {
export function parseRules(rules, flavor, layout) {
return Object.entries(rules).map(([ruleName, rule], index) => {
const ruleNode = {
key: index.toString(),
@@ -34,7 +34,7 @@ export function parseRules(rules, flavor) {
if (isFileScope) {
// The scope for the rule is a file, so we don't need to show the match location address
ruleNode.children = rule.matches.map((match, matchIndex) => {
return parseNode(match[1], `${index}-${matchIndex}`, rules, rule.meta.lib)
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
@@ -56,7 +56,7 @@ export function parseRules(rules, flavor) {
? `${formatHex(match[0].value)}`
: formatDynamicAddress(match[0].value),
},
children: [parseNode(match[1], `${matchKey}`, rules, rule.meta.lib)]
children: [parseNode(match[1], `${matchKey}`, rules, rule.meta.lib, layout)]
}
matchCounter++
return matchNode
@@ -245,7 +245,7 @@ export function parseProcessCapabilities(data, showLibraryRules) {
* @param {boolean} lib - Whether this is a library rule
* @returns {Object} - Parsed node data
*/
function parseNode(node, key, rules, lib) {
function parseNode(node, key, rules, lib, layout) {
if (!node) return null
const isNotStatement = node.node.statement && node.node.statement.type === 'not'
@@ -275,7 +275,7 @@ function parseNode(node, key, rules, lib) {
if (processedNode.children && Array.isArray(processedNode.children)) {
result.children = processedNode.children
.map((child) => {
const childNode = parseNode(child, `${key}`, rules, lib)
const childNode = parseNode(child, `${key}`, rules, lib, layout)
return childNode
})
.filter((child) => child !== null)
@@ -297,11 +297,190 @@ function parseNode(node, key, rules, lib) {
if (processedNode.node.feature && processedNode.node.feature.type === 'regex') {
result.children = processRegexCaptures(processedNode, key);
}
}
// Add call information for dynamic sandbox traces
if (processedNode.node.feature && processedNode.node.feature.type === 'api') {
const callInfo = getCallInfo(node, layout)
if (callInfo) {
result.children.push({
key: key,
data: {
type: 'call-info',
name: callInfo
},
children: []
});
}
}
return result
}
function getCallInfo(node, layout) {
if (!node.locations || node.locations.length === 0) return null;
const location = node.locations[0];
if (location.type !== 'call') return null;
const [ppid, pid, tid, callId] = location.value;
const callName = node.node.feature.api;
const pname = getProcessName(layout, location);
const cname = getCallName(layout, location);
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 callInfo;
}
/**
* 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 DynamicLayout 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 DynamicLayout 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 [];