mirror of
https://github.com/mandiant/capa.git
synced 2026-01-15 14:23:38 -08:00
refactor RuleMatchesTable
This commit: - add two new base CSS utility classes - stores the results of parsing in sessionStorage for reuse - add a new settings option `Show column filters` - replaces ../../../ with a path shortcut
This commit is contained in:
@@ -1,118 +1,171 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<TreeTable
|
||||
:value="filteredTreeData"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
size="small"
|
||||
: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)
|
||||
})
|
||||
}"
|
||||
<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 #header>
|
||||
<div class="flex justify-content-end w-full">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters['global']" placeholder="Global search" />
|
||||
</IconField>
|
||||
<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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<template #filter>
|
||||
<InputText v-model="filters[col.field]" type="text" :placeholder="`Filter by ${col.header}`" />
|
||||
</template>
|
||||
<template #body="slotProps">
|
||||
<!-- Address column -->
|
||||
<span v-if="col.field === 'address'" class="text-sm" style="font-family: monospace">
|
||||
{{ slotProps.node.data.address }}
|
||||
</span>
|
||||
|
||||
<!-- Tactic column -->
|
||||
<div v-else-if="col.field === 'tactic' && 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>
|
||||
|
||||
<!-- MBC column -->
|
||||
<div v-else-if="col.field === 'mbc' && 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>
|
||||
|
||||
<!-- Namespace column -->
|
||||
<span v-else-if="col.field === 'namespace' && !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>
|
||||
<!-- 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>
|
||||
</ContextMenu>
|
||||
<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>
|
||||
|
||||
<Toast />
|
||||
<!-- 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>
|
||||
|
||||
<Dialog v-model:visible="sourceDialogVisible" :style="{ width: '50vw' }">
|
||||
<highlightjs autodetect :code="currentSource" />
|
||||
</Dialog>
|
||||
</div>
|
||||
<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";
|
||||
@@ -124,11 +177,11 @@ 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 "@/components/columns/RuleColumn.vue";
|
||||
import VTIcon from "@/components/misc/VTIcon.vue";
|
||||
|
||||
import { parseRules } from "../utils/rdocParser";
|
||||
import { createMBCHref, createATTACKHref } from "../utils/urlHelpers";
|
||||
import { parseRules } from "@/utils/rdocParser";
|
||||
import { createMBCHref, createATTACKHref, createCapaRulesUrl, createVirusTotalUrl } from "../utils/urlHelpers";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -138,62 +191,90 @@ const props = defineProps({
|
||||
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);
|
||||
showSource(selectedNode.value.data?.source);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "View rule in capa-rules",
|
||||
icon: "pi pi-external-link",
|
||||
target: "_blank",
|
||||
url: selectedNode.value.url
|
||||
url: createCapaRulesUrl(selectedNode.value, props.data.meta.version)
|
||||
},
|
||||
{
|
||||
label: "Lookup rule in VirusTotal",
|
||||
icon: "vt-icon",
|
||||
target: "_blank",
|
||||
url: selectedNode.value.vturl
|
||||
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;
|
||||
// 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/${encodeURIComponent(behaviourSignature)}/files`;
|
||||
|
||||
// show the context menu
|
||||
console.log(menu);
|
||||
menu.value.show(event);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Expand node on click
|
||||
/**
|
||||
* 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, if not return
|
||||
// We only expand rule and match locations, otherwise return
|
||||
if (nodeType !== "rule" && nodeType !== "match location") return;
|
||||
|
||||
// If the node is already expanded, collapse it
|
||||
@@ -211,19 +292,9 @@ const onNodeSelect = (node) => {
|
||||
// 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) {
|
||||
@@ -243,16 +314,33 @@ const filteredTreeData = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
if (props.data && props.data.rules) {
|
||||
treeData.value = parseRules(props.data.rules, props.data.meta.flavor, props.data.meta.analysis.layout);
|
||||
const cacheKey = "ruleMatches";
|
||||
const cachedData = sessionStorage.getItem(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
// If cached data exists, parse and use it
|
||||
treeData.value = JSON.parse(cachedData);
|
||||
} else {
|
||||
console.error("Invalid data prop:", props.data);
|
||||
// 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex align-items-center flex-row gap-3">
|
||||
<div class="flex align-items-center flex-wrap gap-3">
|
||||
<div class="flex flex-row align-items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="showCapabilitiesByFunctionOrProcess"
|
||||
@@ -29,13 +29,22 @@
|
||||
<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, computed, watch } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -52,16 +61,16 @@ const props = defineProps({
|
||||
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-namespace-chart",
|
||||
"update:show-column-filters"
|
||||
]);
|
||||
|
||||
const capabilitiesLabel = computed(() => {
|
||||
return props.flavor === "static" ? "Show capabilities by function" : "Show capabilities by process";
|
||||
});
|
||||
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);
|
||||
@@ -74,4 +83,8 @@ watch(showLibraryRules, (newValue) => {
|
||||
watch(showNamespaceChart, (newValue) => {
|
||||
emit("update:show-namespace-chart", newValue);
|
||||
});
|
||||
|
||||
watch(showColumnFilters, (newValue) => {
|
||||
emit("update:show-column-filters", newValue);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="cursor-default">
|
||||
<!--- example node: "parse PE headers (2 matches) lib" --->
|
||||
<template v-if="node.data.type === 'rule'">
|
||||
<!--- example node: "parse PE headers (2 matches) lib" --->
|
||||
<div class="cursor-pointer">
|
||||
<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" />
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<!--- example node: "basic block @ 0x401000" or "explorer.exe" --->
|
||||
<template v-else-if="node.data.type === 'match location'">
|
||||
<span class="text-sm font-italic cursor-pointer">{{ node.data.name }}</span>
|
||||
<span class="text-sm font-italic">{{ node.data.name }}</span>
|
||||
</template>
|
||||
|
||||
<!--- example node: "- or", "- and" --->
|
||||
@@ -29,17 +29,17 @@
|
||||
|
||||
<!--- 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' }" style="font-family: monospace"
|
||||
>{{ node.data.name }}
|
||||
<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 (children nodes) of regex nodes) --->
|
||||
<!--- 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" style="font-family: monospace">{{ node.data.name }}</span>
|
||||
- <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) --->
|
||||
|
||||
Reference in New Issue
Block a user