Merge pull request #6779 from JakubOnderka/event-report-extract-fix

Event report extract fix
pull/6782/head
Jakub Onderka 2020-12-20 00:51:05 +01:00 committed by GitHub
commit ba624b303c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 183 additions and 149 deletions

View File

@ -209,31 +209,30 @@ class EventReportsController extends AppController
{
if (!$this->request->is('ajax')) {
throw new MethodNotAllowedException(__('This function can only be reached via AJAX.'));
} else {
if ($this->request->is('post')) {
$report = $this->EventReport->fetchIfAuthorized($this->Auth->user(), $reportId, 'edit', $throwErrors=true, $full=false);
$results = $this->EventReport->getComplexTypeToolResultWithReplacements($this->Auth->user(), $report);
$report['EventReport']['content'] = $results['replacementResult']['contentWithReplacements'];
$contextResults = $this->EventReport->extractWithReplacements($this->Auth->user(), $report, ['replace' => true]);
$suggestionResult = $this->EventReport->transformFreeTextIntoSuggestion($contextResults['contentWithReplacements'], $results['complexTypeToolResult']);
$errors = $this->EventReport->applySuggestions($this->Auth->user(), $report, $suggestionResult['contentWithSuggestions'], $suggestionResult['suggestionsMapping']);
if (empty($errors)) {
if (!empty($this->data['EventReport']['tag_event'])) {
$this->EventReport->attachTagsAfterReplacements($this->Auth->User(), $contextResults['replacedContext'], $report['EventReport']['event_id']);
}
$report = $this->EventReport->simpleFetchById($this->Auth->user(), $reportId);
$data = [ 'report' => $report ];
$successMessage = __('Automatic extraction applied to Event Report %s', $reportId);
return $this->__getSuccessResponseBasedOnContext($successMessage, $data, 'applySuggestions', $reportId);
} else {
$errorMessage = __('Automatic extraction could not be applied to Event Report %s.%sReasons: %s', $reportId, PHP_EOL, json_encode($errors));
return $this->__getFailResponseBasedOnContext($errorMessage, array(), 'applySuggestions', $reportId);
}
}
$this->layout = 'ajax';
$this->set('reportId', $reportId);
$this->render('ajax/extractAllFromReport');
}
if ($this->request->is('post')) {
$report = $this->EventReport->fetchIfAuthorized($this->Auth->user(), $reportId, 'edit', $throwErrors=true, $full=false);
$results = $this->EventReport->getComplexTypeToolResultWithReplacements($this->Auth->user(), $report);
$report['EventReport']['content'] = $results['replacementResult']['contentWithReplacements'];
$contextResults = $this->EventReport->extractWithReplacements($this->Auth->user(), $report, ['replace' => true]);
$suggestionResult = $this->EventReport->transformFreeTextIntoSuggestion($contextResults['contentWithReplacements'], $results['complexTypeToolResult']);
$errors = $this->EventReport->applySuggestions($this->Auth->user(), $report, $suggestionResult['contentWithSuggestions'], $suggestionResult['suggestionsMapping']);
if (empty($errors)) {
if (!empty($this->data['EventReport']['tag_event'])) {
$this->EventReport->attachTagsAfterReplacements($this->Auth->User(), $contextResults['replacedContext'], $report['EventReport']['event_id']);
}
$report = $this->EventReport->simpleFetchById($this->Auth->user(), $reportId);
$data = [ 'report' => $report ];
$successMessage = __('Automatic extraction applied to Event Report %s', $reportId);
return $this->__getSuccessResponseBasedOnContext($successMessage, $data, 'applySuggestions', $reportId);
} else {
$errorMessage = __('Automatic extraction could not be applied to Event Report %s.%sReasons: %s', $reportId, PHP_EOL, json_encode($errors));
return $this->__getFailResponseBasedOnContext($errorMessage, array(), 'applySuggestions', $reportId);
}
}
$this->layout = 'ajax';
$this->set('reportId', $reportId);
$this->render('ajax/extractAllFromReport');
}
public function extractFromReport($reportId)

View File

@ -1116,10 +1116,13 @@ class TagsController extends AppController
'conditions' => $conditions,
'recursive' => -1
));
if (!$searchIfTagExists && empty($tags)) {
$tags = [];
foreach ($tag as $i => $tagName) {
$tags[] = ['Tag' => ['name' => $tagName], 'simulatedTag' => true];
if (!$searchIfTagExists) {
$foundTagNames = Hash::extract($tags, "{n}.Tag.name");
foreach ($tag as $tagName) {
if (!in_array($tagName, $foundTagNames, true)) {
// Tag not found, insert simulated tag
$tags[] = ['Tag' => ['name' => $tagName], 'simulatedTag' => true];
}
}
}
$this->loadModel('Taxonomy');

View File

@ -234,6 +234,7 @@ class ComplexTypeTool
if (isset($resultArray[$typeArray['value']])) {
continue;
}
$typeArray['original_value'] = $ioc;
$resultArray[$typeArray['value']] = $typeArray;
}
return array_values($resultArray);

View File

@ -521,7 +521,8 @@ class EventReport extends AppModel
return $errors;
}
public function applySuggestions($user, $report, $contentWithSuggestions, $suggestionsMapping) {
public function applySuggestions(array $user, array $report, $contentWithSuggestions, array $suggestionsMapping)
{
$errors = [];
$replacedContent = $contentWithSuggestions;
$success = 0;
@ -546,10 +547,10 @@ class EventReport extends AppModel
return $errors;
}
public function applySuggestionsInText($contentWithSuggestions, $attribute, $value)
public function applySuggestionsInText($contentWithSuggestions, array $attribute, $value)
{
$textToBeReplaced = sprintf('@[suggestion](%s)', $value);
$textToInject = sprintf('@[attribute](%s)', $attribute['Attribute']['uuid']);
$textToBeReplaced = "@[suggestion]($value)";
$textToInject = "@[attribute]({$attribute['Attribute']['uuid']})";
$replacedContent = str_replace($textToBeReplaced, $textToInject, $contentWithSuggestions);
return $replacedContent;
}
@ -642,25 +643,36 @@ class EventReport extends AppModel
];
}
public function transformFreeTextIntoSuggestion($content, $complexTypeToolResult)
public function transformFreeTextIntoSuggestion($content, array $complexTypeToolResult)
{
$replacedContent = $content;
$suggestionsMapping = [];
$typeToCategoryMapping = $this->Event->Attribute->typeToCategoryMapping();
foreach ($complexTypeToolResult as $i => $complexTypeToolEntry) {
// Sort by original value string length, longest values first
usort($complexTypeToolResult, function ($a, $b) {
$strlenA = strlen($a['original_value']);
$strlenB = strlen($b['original_value']);
if ($strlenA === $strlenB) {
return 0;
}
return ($strlenA < $strlenB) ? 1 : -1;
});
$suggestionsMapping = [];
foreach ($complexTypeToolResult as $complexTypeToolEntry) {
$textToBeReplaced = $complexTypeToolEntry['value'];
$textToInject = sprintf('@[suggestion](%s)', $textToBeReplaced);
$textToInject = "@[suggestion]($textToBeReplaced)";
$suggestionsMapping[$textToBeReplaced] = [
'category' => $typeToCategoryMapping[$complexTypeToolEntry['default_type']][0],
'type' => $complexTypeToolEntry['default_type'],
'value' => $textToBeReplaced,
'to_ids' => $complexTypeToolEntry['to_ids'],
];
$replacedContent = str_replace($textToBeReplaced, $textToInject, $replacedContent);
$replacedContent = str_replace($complexTypeToolEntry['original_value'], $textToInject, $replacedContent);
}
return [
'contentWithSuggestions' => $replacedContent,
'suggestionsMapping' => $suggestionsMapping
'suggestionsMapping' => $suggestionsMapping,
];
}
@ -674,21 +686,17 @@ class EventReport extends AppModel
return $complexTypeToolResult;
}
public function getComplexTypeToolResultFromReport($content)
public function getComplexTypeToolResultWithReplacements(array $user, array $report)
{
App::uses('ComplexTypeTool', 'Tools');
$complexTypeTool = new ComplexTypeTool();
$this->Warninglist = ClassRegistry::init('Warninglist');
$complexTypeTool->setTLDs($this->Warninglist->fetchTLDLists());
$complexTypeToolResult = $complexTypeTool->checkComplexRouter($content, 'freetext');
return $complexTypeToolResult;
}
public function getComplexTypeToolResultWithReplacements($user, $report)
{
$complexTypeToolResult = $this->getComplexTypeToolResultFromReport($report['EventReport']['content']);
$complexTypeToolResult = $complexTypeTool->checkFreeText($report['EventReport']['content']);
$replacementResult = $this->transformFreeTextIntoReplacement($user, $report, $complexTypeToolResult);
$complexTypeToolResult = $this->getComplexTypeToolResultFromReport($replacementResult['contentWithReplacements']);
$complexTypeToolResult = $complexTypeTool->checkFreeText($replacementResult['contentWithReplacements']);
return [
'complexTypeToolResult' => $complexTypeToolResult,
'replacementResult' => $replacementResult,
@ -700,6 +708,7 @@ class EventReport extends AppModel
*
* @param array $user
* @param array $report
* @param array $options
* @return array
*/
public function extractWithReplacements(array $user, array $report, array $options = [])
@ -713,7 +722,6 @@ class EventReport extends AppModel
'attack' => true,
];
$options = array_merge($baseOptions, $options);
$originalContent = $report['EventReport']['content'];
$this->GalaxyCluster = ClassRegistry::init('GalaxyCluster');
$mitreAttackGalaxyId = $this->GalaxyCluster->Galaxy->getMitreAttackGalaxyId();
$clusterContain = ['Tag'];
@ -734,17 +742,21 @@ class EventReport extends AppModel
'contain' => $clusterContain
]);
$originalContent = $report['EventReport']['content'];
// Remove all existing event report markers
$content = preg_replace("/@\[(attribute|tag|galaxymatrix)]\([^)]*\)/", '', $originalContent);
if ($options['tags']) {
$this->Tag = ClassRegistry::init('Tag');
$tags = $this->Tag->fetchUsableTags($user);
foreach ($tags as $i => $tag) {
foreach ($tags as $tag) {
$tagName = $tag['Tag']['name'];
$found = $this->isValidReplacementTag($originalContent, $tagName);
$found = $this->isValidReplacementTag($content, $tagName);
if ($found) {
$replacedContext[$tagName][$tagName] = $tag['Tag'];
} else {
$tagNameUpper = strtoupper($tagName);
$found = $this->isValidReplacementTag($originalContent, $tagNameUpper);
$found = $this->isValidReplacementTag($content, $tagNameUpper);
if ($found) {
$replacedContext[$tagNameUpper][$tagName] = $tag['Tag'];
}
@ -752,10 +764,10 @@ class EventReport extends AppModel
}
}
foreach ($clusters as $i => $cluster) {
foreach ($clusters as $cluster) {
$cluster['GalaxyCluster']['colour'] = '#0088cc';
$tagName = $cluster['GalaxyCluster']['tag_name'];
$found = $this->isValidReplacementTag($originalContent, $tagName);
$found = $this->isValidReplacementTag($content, $tagName);
if ($found) {
$replacedContext[$tagName][$tagName] = $cluster['GalaxyCluster'];
}
@ -765,10 +777,10 @@ class EventReport extends AppModel
$replacedContext[$cluster['GalaxyCluster']['value']][$tagName] = $cluster['GalaxyCluster'];
}
if ($options['synonyms']) {
foreach ($cluster['GalaxyElement'] as $j => $element) {
foreach ($cluster['GalaxyElement'] as $element) {
if (strlen($element['value']) >= $options['synonyms_min_characters']) {
$toSearch = ' ' . $element['value'] . ' ';
$found = strpos($originalContent, $toSearch) !== false;
$found = strpos($content, $toSearch) !== false;
if ($found) {
$replacedContext[$element['value']][$tagName] = $cluster['GalaxyCluster'];
}
@ -783,22 +795,22 @@ class EventReport extends AppModel
'conditions' => ['GalaxyCluster.galaxy_id' => $mitreAttackGalaxyId],
'contain' => $clusterContain
]);
foreach ($attackClusters as $i => $cluster) {
foreach ($attackClusters as $cluster) {
$cluster['GalaxyCluster']['colour'] = '#0088cc';
$tagName = $cluster['GalaxyCluster']['tag_name'];
$toSearch = ' ' . $cluster['GalaxyCluster']['value'] . ' ';
$found = strpos($originalContent, $toSearch) !== false;
$found = strpos($content, $toSearch) !== false;
if ($found) {
$replacedContext[$cluster['GalaxyCluster']['value']][$tagName] = $cluster['GalaxyCluster'];
} else {
$clusterParts = explode(' - ', $cluster['GalaxyCluster']['value'], 2);
$toSearch = ' ' . $clusterParts[0] . ' ';
$found = strpos($originalContent, $toSearch) !== false;
$found = strpos($content, $toSearch) !== false;
if ($found) {
$replacedContext[$clusterParts[0]][$tagName] = $cluster['GalaxyCluster'];
} else {
} else if (isset($clusterParts[1])) {
$toSearch = ' ' . $clusterParts[1] . ' ';
$found = strpos($originalContent, $toSearch) !== false;
$found = strpos($content, $toSearch) !== false;
if ($found) {
$replacedContext[$clusterParts[1]][$tagName] = $cluster['GalaxyCluster'];
}
@ -810,14 +822,32 @@ class EventReport extends AppModel
'replacedContext' => $replacedContext
];
if ($options['replace']) {
// Sort by original value string length, longest values first
uksort($replacedContext, function ($a, $b) {
$strlenA = strlen($a);
$strlenB = strlen($b);
if ($strlenA === $strlenB) {
return 0;
}
return ($strlenA < $strlenB) ? 1 : -1;
});
$content = $originalContent;
$secondPassReplace = [];
// Replace in two pass to prevent double replace
$id = 0;
foreach ($replacedContext as $rawText => $replacements) {
// Replace with first one until a better strategy is found
reset($replacements);
$replacement = key($replacements);
$textToInject = sprintf('@[tag](%s)', $replacement);
$content = str_replace($rawText, $textToInject, $content);
++$id;
$content = str_replace($rawText, "@[mark]($id)", $content);
$secondPassReplace[$id] = "@[tag]($replacement)";
}
$content = preg_replace_callback("/@\[mark]\(([^)]*)\)/", function ($matches) use ($secondPassReplace) {
return $secondPassReplace[$matches[1]];
}, $content);
$toReturn['contentWithReplacements'] = $content;
}
return $toReturn;
@ -835,7 +865,6 @@ class EventReport extends AppModel
'event_id' => $event_id,
'url' => $url
];
$module = $this->isFetchURLModuleEnabled();
if (!empty($module)) {
$result = $this->Module->queryModuleServer($modulePayload, false);
if (empty($result['results'][0]['values'][0])) {
@ -853,7 +882,7 @@ class EventReport extends AppModel
}
/**
* findValidReplacementTag Search if tagName is in content and is not wrapped in a tag reference
* findValidReplacementTag Search if tagName is in content
*
* @param string $content
* @param string $tagName
@ -861,25 +890,8 @@ class EventReport extends AppModel
*/
private function isValidReplacementTag($content, $tagName)
{
$lastIndex = 0;
$allIndices = [];
$toSearch = strpos($tagName, ':') === false ? ' ' . $tagName . ' ' : $tagName;
while (($lastIndex = strpos($content, $toSearch, $lastIndex)) !== false) {
$allIndices[] = $lastIndex;
$lastIndex = $lastIndex + strlen($toSearch);
}
if (empty($allIndices)) {
return false;
} else {
$wrapper = '@[tag](';
foreach ($allIndices as $i => $index) {
$stringBeforeTag = substr($content, $index - strlen($wrapper), strlen($wrapper));
if ($stringBeforeTag != $wrapper) {
return true;
}
}
return false;
}
return strpos($content, $toSearch) !== false;
}
public function attachTagsAfterReplacements($user, $replacedContext, $eventId)

View File

@ -509,7 +509,7 @@ function renderMISPElement(scope, elementID, indexes) {
type: suggestion.complexTypeToolResult.picked_type,
origValue: elementID,
value: suggestion.complexTypeToolResult.value,
'indexStart': indexes.start,
indexStart: indexes.start,
suggestionkey: suggestionKey,
checked: suggestion.checked
})
@ -651,23 +651,34 @@ function attachRemoteMISPElements() {
$div.html(cache_matrix[cacheKey])
}
})
var tagNamesToLoad = [];
var tagsLoading = [];
$('.embeddedTag[data-scope="tag"]').each(function() {
var $div = $(this)
$div.append($('<span/>').append(loadingSpanAnimation))
var eventID = $div.data('eventid')
var elementID = $div.data('elementid')
var cacheKey = eventid + '-' + elementID
clearTimeout(tagTimers[cacheKey]);
if (cache_tag[cacheKey] === undefined) {
tagTimers[cacheKey] = setTimeout(function() {
fetchTagInfo(eventID, elementID, function() {
attachTagInfo($div, eventID, elementID, true)
})
}, firstCustomPostRenderCall ? 0 : slowDebounceDelay);
var $div = $(this);
var elementID = $div.data('elementid');
if (!(elementID in cache_tag)) {
$div.append($('<span/>').append(loadingSpanAnimation));
tagNamesToLoad.push(elementID);
tagsLoading.push($div);
} else {
$div.html(cache_tag[cacheKey])
$div.html(cache_tag[elementID]);
}
})
}).promise().done(function() {
if (tagNamesToLoad.length === 0) {
return;
}
fetchTagInfo(tagNamesToLoad, function() {
$.each(tagsLoading, function() {
var $div = $(this);
var elementID = $div.data('elementid');
if (elementID in cache_tag) {
$div.html(cache_tag[elementID]);
}
});
});
});
if (firstCustomPostRenderCall) {
// Wait, because .each calls are asynchronous
setTimeout(function() {
@ -720,56 +731,53 @@ function attachGalaxyMatrix($elem, eventid, elementID) {
})
}
function attachTagInfo($elem, eventid, elementID, all) {
var cacheKey = eventid + '-' + elementID
$elem.html(cache_tag[cacheKey])
if (all === true) {
$('.embeddedTag[data-scope="tag"]').filter(function() {
return $(this).data('eventid') == eventid && $(this).data('elementid') == elementID
}).html(cache_tag[cacheKey])
}
}
function fetchTagInfo(eventid, tagName, callback) {
function fetchTagInfo(tagNames, callback) {
$.ajax({
data: {
"tag": tagName,
"tag": tagNames,
},
success:function(data, textStatus) {
var $tag
success: function (data) {
var $tag, tagName;
data = $.parseJSON(data)
var tagData;
for (var i = 0; i < data.length; i++) {
var tag = data[i];
if (tag.Tag.name == tagName) {
tagData = data[i]
break
tagName = tag.Tag.name;
proxyMISPElements['tag'][tagName] = tag;
$tag = getTagReprensentation(tag);
cache_tag[tagName] = $tag[0].outerHTML;
}
// If tag name doesn't exists, construct empty placeholder
for (i = 0; i < tagNames.length; i++) {
tagName = tagNames[i];
if (!(tagName in cache_tag)) {
$tag = constructTagHtml(tagName, '#ffffff', {'border': '1px solid #000'});
cache_tag[tagName] = $tag[0].outerHTML;
}
}
if (tagData === undefined) {
tagData = {}
$tag = constructTagHtml(tagName, '#ffffff', {'border': '1px solid #000'})
} else {
$tag = getTagReprensentation(tagData)
proxyMISPElements['tag'][tagName] = tagData
},
error: function (jqXHR, textStatus, errorThrown) {
// Query failed, fill cache with placeholder
var tagName, templateVariables;
for (var i = 0; i < tagNames.length; i++) {
tagName = tagNames[i];
if (!(tagName in cache_tag)) {
templateVariables = sanitizeObject({
scope: 'Error while fetching tag',
id: tagName
});
cache_tag[tagName] = dotTemplateInvalid(templateVariables);
}
}
var cacheKey = eventid + '-' + tagName
cache_tag[cacheKey] = $tag[0].outerHTML;
},
error: function(jqXHR, textStatus, errorThrown) {
var templateVariables = sanitizeObject({
scope: 'Error while fetching tag',
id: tagName
})
var placeholder = dotTemplateInvalid(templateVariables)
cache_tag[cacheKey] = placeholder;
},
complete: function() {
complete: function () {
if (callback !== undefined) {
callback()
}
},
type:"post",
type: "post",
url: baseurl + "/tags/search/0/1/0"
})
}
@ -947,7 +955,14 @@ function highlightPickedReplacementInReport() {
function convertEntityIntoSuggestion(content, entity) {
var converted = ''
var entityValue = entity.importRegexMatch ? entity.importRegexMatch : entity.value
var entityValue;
if (entity.importRegexMatch) {
entityValue = entity.importRegexMatch;
} else if (entity.original_value) {
entityValue = entity.original_value;
} else {
entityValue = entity.value;
}
var splittedContent = content.split(entityValue)
splittedContent.forEach(function(text, i) {
converted += text
@ -1007,7 +1022,7 @@ function constructSuggestionMapping(entity, indicesInCM) {
function injectNumberOfOccurrencesInReport(entities) {
var content = getEditorData()
entities.forEach(function(entity, i) {
entities[i].occurrences = getAllIndicesOf(content, entity.value, false, false).length
entities[i].occurrences = getAllIndicesOf(content, entity.original_value, false, false).length
})
return entities
}
@ -1089,7 +1104,7 @@ function pickSuggestionColumn(index, tableID, force) {
tr: $tr,
index: index
}
if (tableID == 'replacementTable') {
if (tableID === 'replacementTable') {
var uuid = $tr.find('select.attribute-replacement').val()
pickedSuggestion['entity'] = {
value: $tr.data('attributeValue'),
@ -1100,7 +1115,7 @@ function pickSuggestionColumn(index, tableID, force) {
pickedSuggestion['entity']['importRegexMatch'] = proxyMISPElements['attribute'][uuid].importRegexValue
}
highlightPickedReplacementInReport()
} else if (tableID == 'contextReplacementTable') {
} else if (tableID === 'contextReplacementTable') {
pickedSuggestion['entity'] = {
value: $tr.data('contextValue'),
picked_type: 'tag',
@ -1211,7 +1226,11 @@ function submitExtractionSuggestion() {
}
},
error: function(jqXHR, textStatus, errorThrown) {
showMessage('fail', saveFailedMessage + ': ' + errorThrown);
if (jqXHR.responseJSON) {
showMessage('fail', jqXHR.responseJSON.errors);
} else {
showMessage('fail', saveFailedMessage + ': ' + errorThrown);
}
},
complete:function() {
$('#temp').remove();
@ -1412,10 +1431,11 @@ function constructTag(tagName) {
function getTagReprensentation(tagData) {
var $tag
if(tagData.GalaxyCluster !== undefined) {
if (tagData.GalaxyCluster !== undefined) {
$tag = constructClusterTagHtml(tagData)
} else {
$tag = constructTagHtml(tagData.Tag.name, tagData.Tag.colour)
var color = tagData.Tag.colour ? tagData.Tag.colour : tagData.TaxonomyPredicate.colour;
$tag = constructTagHtml(tagData.Tag.name, color)
}
return $tag
}
@ -1480,8 +1500,7 @@ function constructTaxonomyInfo(tagData) {
}
function constructGalaxyInfo(tagData) {
var cacheKey = eventid + '-' + tagData.Tag.name
var tagHTML = cache_tag[cacheKey]
var tagHTML = cache_tag[tagData.Tag.name]
var $tag = $(tagHTML)
var $cluster = $('<div/>').append(
$('<span/>').append($tag),
@ -2011,16 +2030,14 @@ function isDoubleExtraction(content) {
function getAllIndicesOf(haystack, needle, caseSensitive, requestLineNum) {
var indices = []
if (needle.length == 0) {
if (needle.length === 0) {
return indices
}
var startIndex = 0, index, indices = [];
var startIndex = 0, index = 0;
if (!caseSensitive) {
needle = needle.toLowerCase();
haystack = haystack.toLowerCase();
}
var startIndex = 0
var index = 0
while (true) {
index = haystack.indexOf(needle, startIndex)
if (index === -1) {

View File

@ -633,14 +633,16 @@ function createRulesMenuItem(itemName, icon, ruleScope, ruleName) {
function createMenuItem(itemName, icon, clickHandler) {
return $('<li/>').append(
$('<a/>').attr('tabindex', '-1').attr('href', '#').click(clickHandler).append(
$('<a/>').attr('tabindex', '-1').attr('href', '#').click(function (event) {
event.preventDefault();
clickHandler();
}).append(
$('<span/>').addClass('icon').append(
icon instanceof jQuery ? icon : $('<i/>').addClass(icon)
),
$('<span/>').text(' ' + itemName)
)
)
)
}
function createSubMenu(submenuConfig) {
@ -805,4 +807,4 @@ var syncSrcScroll = function () {
break;
}
cm.scrollTo(0, line*cm.defaultTextHeight())
}
}