array( 'notEmpty' => [ 'rule' => 'valueNotEmpty', ], 'unique' => [ 'rule' => 'isUnique', 'message' => 'Warninglist with same name already exists.' ], ), 'description' => array( 'rule' => array('valueNotEmpty'), ), 'version' => array( 'rule' => array('numeric'), ), 'type' => [ 'rule' => ['inList', ['cidr', 'hostname', 'string', 'substring', 'regex']], ], 'category' => [ 'rule' => ['inList', ['false_positive', 'known']], ], 'entries' => [ 'notEmpty' => [ 'rule' => 'valueNotEmpty', ], ] ); public $hasMany = array( 'WarninglistEntry' => array( 'dependent' => true ), 'WarninglistType' => array( 'dependent' => true ) ); const TLDS = array( 'TLDs as known by IANA' ); /** @var array */ private $entriesCache = []; /** @var array|null */ private $enabledCache = null; private $showForAll; public function __construct($id = false, $table = null, $ds = null) { parent::__construct($id, $table, $ds); $this->showForAll = Configure::read('MISP.warning_for_all'); } public function beforeValidate($options = array()) { if (isset($this->data['WarninglistEntry'])) { if ($this->data['Warninglist']['type'] === 'cidr') { foreach ($this->data['WarninglistEntry'] as $entry) { if (!CidrTool::validate($entry['value'])) { $this->validationErrors['entries'][] = __('`%s` is not valid CIDR', $entry['value']); } } } else if ($this->data['Warninglist']['type'] === 'regex') { foreach ($this->data['WarninglistEntry'] as $entry) { if (@preg_match($entry['value'], '') === false) { $this->validationErrors['entries'][] = __('`%s` is not valid regular expression', $entry['value']); } } } if (!empty($this->validationErrors['entries'])) { return false; } } return true; } /** * Attach warninglist matches to attributes or proposals with IDS mark. * * @param array $attributes * @return array Warninglist ID => name */ public function attachWarninglistToAttributes(array &$attributes) { if (empty($attributes)) { return []; } $enabledWarninglists = $this->getEnabled(); if (empty($enabledWarninglists)) { return []; // no warninglist is enabled } try { $redis = RedisTool::init(); } catch (Exception $e) { // fallback to default implementation when redis is not available $eventWarnings = []; foreach ($attributes as $pos => $attribute) { $attributes[$pos] = $this->checkForWarning($attribute, $enabledWarninglists); if (isset($attributes[$pos]['warnings'])) { foreach ($attribute['warnings'] as $match) { $eventWarnings[$match['warninglist_id']] = $match['warninglist_name']; } } } if (!empty($eventWarnings)) { $this->assignComments($attributes); } return $eventWarnings; } $warninglists = []; $enabledTypes = []; foreach ($enabledWarninglists as $warninglist) { $warninglists[$warninglist['Warninglist']['id']] = $warninglist['Warninglist']; foreach ($warninglist['types'] as $type) { $enabledTypes[$type] = true; } } $redisResultToAttributePos = []; $keysToGet = []; foreach ($attributes as $pos => $attribute) { if (($attribute['to_ids'] || $this->showForAll) && (isset($enabledTypes[$attribute['type']]) || isset($enabledTypes['ALL']))) { $redisResultToAttributePos[] = $pos; // Use hash as binary string to save memory and CPU time // Hash contains just attribute type and value, so can be reused in another event attributes $keysToGet[] = 'misp:wlc:' . md5($attribute['type'] . ':' . $attribute['value'], true); } } if (empty($keysToGet)) { return []; // no attribute suitable for warninglist check } $eventWarnings = []; $saveToCache = []; foreach ($redis->mget($keysToGet) as $pos => $result) { if ($result === false) { // not in cache $attribute = $attributes[$redisResultToAttributePos[$pos]]; $attribute = $this->checkForWarning($attribute, $enabledWarninglists); $store = []; if (isset($attribute['warnings'])) { foreach ($attribute['warnings'] as $match) { $warninglistId = $match['warninglist_id']; $attributes[$redisResultToAttributePos[$pos]]['warnings'][] = [ 'value' => $match['value'], 'match' => $match['match'], 'warninglist_id' => $warninglistId, 'warninglist_name' => $warninglists[$warninglistId]['name'], 'warninglist_category' => $warninglists[$warninglistId]['category'], ]; $eventWarnings[$warninglistId] = $warninglists[$warninglistId]['name']; $store[$warninglistId] = [$match['value'], $match['match']]; } } $attributeKey = $keysToGet[$pos]; $saveToCache[$attributeKey] = empty($store) ? '' : RedisTool::serialize($store); } elseif (!empty($result)) { // skip empty string that means no warning list match $matchedWarningList = RedisTool::deserialize($result); foreach ($matchedWarningList as $warninglistId => $matched) { $attributes[$redisResultToAttributePos[$pos]]['warnings'][] = [ 'value' => $matched[0], 'match' => $matched[1], 'warninglist_id' => $warninglistId, 'warninglist_name' => $warninglists[$warninglistId]['name'], 'warninglist_category' => $warninglists[$warninglistId]['category'], ]; $eventWarnings[$warninglistId] = $warninglists[$warninglistId]['name']; } } } if (!empty($saveToCache)) { $pipe = $redis->pipeline(); foreach ($saveToCache as $attributeKey => $json) { $redis->setex($attributeKey, 8 * 3600, $json); // cache for eight hour } $pipe->exec(); } if (!empty($eventWarnings)) { $this->assignComments($attributes); } return $eventWarnings; } /** * Assign comments to warninglist hits. * @param array $attributes */ private function assignComments(array &$attributes) { $toFetch = []; foreach ($attributes as $attribute) { if (isset($attribute['warnings'])) { foreach ($attribute['warnings'] as $warning) { $toFetch[$warning['warninglist_id']][] = $warning['match']; } } } $conditions = []; foreach ($toFetch as $warninglistId => $values) { $conditions[] = ['AND' => [ 'warninglist_id' => $warninglistId, 'value' => array_unique($values), ]]; } $entries = $this->WarninglistEntry->find('all', [ 'conditions' => [ 'OR' => $conditions, 'comment !=' => '', ], 'fields' => ['value', 'warninglist_id', 'comment'], ]); if (empty($entries)) { return; } $comments = []; foreach ($entries as $entry) { $entry = $entry['WarninglistEntry']; $comments[$entry['warninglist_id']][$entry['value']] = $entry['comment']; } foreach ($attributes as &$attribute) { if (isset($attribute['warnings'])) { foreach ($attribute['warnings'] as &$warning) { if (isset($comments[$warning['warninglist_id']][$warning['match']])) { $warning['comment'] = $comments[$warning['warninglist_id']][$warning['match']]; } } } } } public function update() { // Fetch existing default warninglists $existingWarninglist = $this->find('all', [ 'fields' => ['id', 'name', 'version', 'enabled'], 'recursive' => -1, 'conditions' => ['default' => 1], ]); $existingWarninglist = array_column(array_column($existingWarninglist, 'Warninglist'), null, 'name'); $directories = glob(APP . 'files' . DS . 'warninglists' . DS . 'lists' . DS . '*', GLOB_ONLYDIR); $result = ['success' => [], 'fails' => []]; foreach ($directories as $dir) { $list = FileAccessTool::readJsonFromFile($dir . DS . 'list.json'); if (!isset($list['version'])) { $list['version'] = 1; } if (!isset($list['type'])) { $list['type'] = 'string'; } elseif (is_array($list['type'])) { $list['type'] = $list['type'][0]; } if (!isset($existingWarninglist[$list['name']]) || $list['version'] > $existingWarninglist[$list['name']]['version']) { $current = $existingWarninglist[$list['name']] ?? []; try { $id = $this->__updateList($list, $current); $result['success'][$id] = ['name' => $list['name'], 'new' => $list['version']]; if (!empty($current)) { $result['success'][$id]['old'] = $current['version']; } } catch (Exception $e) { $result['fails'][] = ['name' => $list['name'], 'fail' => $e->getMessage()]; } } } if (!empty($result['success']) || !empty($result['fails'])) { $this->regenerateWarninglistCaches(); } return $result; } public function quickDelete($id) { $result = $this->WarninglistEntry->deleteAll( array('WarninglistEntry.warninglist_id' => $id), false ); if ($result) { $result = $this->WarninglistType->deleteAll( array('WarninglistType.warninglist_id' => $id), false ); } if ($result) { $result = $this->delete($id, false); } return $result; } /** * Import single warninglist * @param array $list * @return int Warninglist ID * @throws Exception */ public function import(array $list) { $existingWarninglist = $this->find('first', [ 'fields' => ['id', 'name', 'version', 'enabled', 'default'], 'recursive' => -1, 'conditions' => ['name' => $list['name']], ]); if ($existingWarninglist && $existingWarninglist['Warninglist']['default']) { throw new Exception('It is not possible to modify default warninglist.'); } $id = $this->__updateList($list, $existingWarninglist ? $existingWarninglist['Warninglist']: [], false); $this->regenerateWarninglistCaches($id); return $id; } /** * @param array $list * @param array $existing * @param bool $default * @return int Warninglist ID * @throws Exception */ private function __updateList(array $list, array $existing, $default = true) { $list['enabled'] = 0; $warninglist = []; if (!empty($existing)) { if ($existing['enabled']) { $list['enabled'] = 1; } $warninglist['Warninglist']['id'] = $existing['id']; // keep list ID // Delete all dependencies $this->WarninglistEntry->deleteAll(['WarninglistEntry.warninglist_id' => $existing['id']], false); $this->WarninglistType->deleteAll(['WarninglistType.warninglist_id' => $existing['id']], false); } $fieldsToSave = array('name', 'version', 'description', 'type', 'enabled'); foreach ($fieldsToSave as $fieldToSave) { $warninglist['Warninglist'][$fieldToSave] = $list[$fieldToSave]; } if (!$default) { $warninglist['Warninglist']['default'] = 0; } $this->create(); if (!$this->save($warninglist)) { throw new Exception("Could not save warninglist because of validation errors: " . json_encode($this->validationErrors)); } $db = $this->getDataSource(); $warninglistId = (int)$this->id; $result = true; if (JsonTool::arrayIsList($list['list'])) { foreach (array_chunk($list['list'], 1000) as $chunk) { $valuesToInsert = []; foreach ($chunk as $value) { if (!empty($value)) { $valuesToInsert[] = [$value, $warninglistId]; } } $result = $db->insertMulti('warninglist_entries', ['value', 'warninglist_id'], $valuesToInsert); } } else { // import warninglist with comments foreach (array_chunk($list['list'], 1000, true) as $chunk) { $valuesToInsert = []; foreach ($chunk as $value => $comment) { if (!empty($value)) { $valuesToInsert[] = [$value, $comment, $warninglistId]; } } $result = $db->insertMulti('warninglist_entries', ['value', 'comment', 'warninglist_id'], $valuesToInsert); } } if (!$result) { throw new Exception('Could not insert values.'); } if (empty($list['matching_attributes'])) { $list['matching_attributes'] = ['ALL']; } $values = []; foreach ($list['matching_attributes'] as $type) { $values[] = array('type' => $type, 'warninglist_id' => $warninglistId); } $this->WarninglistType->saveMany($values); return $warninglistId; } /** * Regenerate the warninglist caches, but if an ID is passed along, only regen the entries for the given ID. * This allows us to enable/disable a single warninglist without regenerating all caches. * @param int|null $id * @return bool * @throws RedisException */ public function regenerateWarninglistCaches($id = null) { try { $redis = RedisTool::init(); } catch (Exception $e) { return false; } $keysToDelete = ['misp:wlc:*']; if ($id === null) { // delete all cached entries when regenerating whole cache $keysToDelete[] = 'misp:warninglist_entries_cache:*'; } RedisTool::deleteKeysByPattern($redis, $keysToDelete); $warninglists = $this->getEnabledAndCacheWarninglist(); foreach ($warninglists as $warninglist) { if ($id && $warninglist['Warninglist']['id'] != $id) { continue; } $entries = $this->WarninglistEntry->find('column', array( 'conditions' => array('warninglist_id' => $warninglist['Warninglist']['id']), 'fields' => array('value') )); $this->cacheWarninglistEntries($entries, $warninglist['Warninglist']['id']); } return true; } /** * Get enable warninglists and cache them. * @return array */ private function getEnabledAndCacheWarninglist() { $warninglists = $this->find('all', [ 'contain' => ['WarninglistType'], 'conditions' => ['enabled' => 1], 'fields' => ['id', 'name', 'type', 'category'], ]); // Convert type to array foreach ($warninglists as &$warninglist) { $warninglist['types'] = []; foreach ($warninglist['WarninglistType'] as $wt) { $warninglist['types'][] = $wt['type']; } unset($warninglist['WarninglistType']); } try { RedisTool::init()->set('misp:warninglist_cache', RedisTool::serialize($warninglists)); } catch (Exception $e) { } return $warninglists; } private function cacheWarninglistEntries(array $warninglistEntries, $id) { try { $redis = RedisTool::init(); } catch (Exception $e) { return false; } $key = 'misp:warninglist_entries_cache:' . $id; RedisTool::unlink($redis, $key); if (method_exists($redis, 'saddArray')) { $redis->sAddArray($key, $warninglistEntries); } else { foreach ($warninglistEntries as $entry) { $redis->sAdd($key, $entry); } } return true; } /** * @return array * @throws JsonException */ public function getEnabled() { if (isset($this->enabledCache)) { return $this->enabledCache; } try { $warninglists = RedisTool::deserialize(RedisTool::init()->get('misp:warninglist_cache')); } catch (Exception $e) { $warninglists = false; } // $warninglists is false when nothing is cached if ($warninglists === false) { $warninglists = $this->getEnabledAndCacheWarninglist(); } $this->enabledCache = $warninglists; return $warninglists; } /** * @param int $id * @return array */ private function getWarninglistEntries($id) { try { $entries = RedisTool::init()->sMembers('misp:warninglist_entries_cache:' . $id); if (!empty($entries)) { return $entries; } } catch (Exception $e) {} $entries = $this->WarninglistEntry->find('column', array( 'conditions' => array('warninglist_id' => $id), 'fields' => array('WarninglistEntry.value') )); $this->cacheWarninglistEntries($entries, $id); return $entries; } /** * For 'hostname', 'string' and 'cidr' warninglist type, values are just in keys to save memory. * * @param array $warninglist * @return array */ public function getFilteredEntries(array $warninglist) { $id = $warninglist['Warninglist']['id']; if (isset($this->entriesCache[$id])) { return $this->entriesCache[$id]; } $values = $this->getWarninglistEntries($id); if ($warninglist['Warninglist']['type'] === 'hostname') { $output = []; foreach ($values as $v) { $v = strtolower(trim($v, '.')); $output[$v] = true; } $values = $output; } else if ($warninglist['Warninglist']['type'] === 'string') { $output = []; foreach ($values as $v) { $output[$v] = true; } $values = $output; } else if ($warninglist['Warninglist']['type'] === 'cidr') { $values = new CidrTool($values); } $this->entriesCache[$id] = $values; return $values; } /** * @param array $object * @param array|null $warninglists If null, all enabled warninglists will be used * @return array */ public function checkForWarning(array $object, $warninglists = null) { if ($warninglists === null) { $warninglists = $this->getEnabled(); } if ($object['to_ids'] || $this->showForAll) { foreach ($warninglists as $list) { if (in_array('ALL', $list['types'], true) || in_array($object['type'], $list['types'], true)) { $result = $this->checkValue($this->getFilteredEntries($list), $object['value'], $object['type'], $list['Warninglist']['type']); if ($result !== false) { $object['warnings'][] = array( 'match' => $result[0], 'value' => $result[1], 'warninglist_id' => $list['Warninglist']['id'], 'warninglist_name' => $list['Warninglist']['name'], 'warninglist_category' => $list['Warninglist']['category'], ); } } } } return $object; } /** * @param array|CidrTool $listValues * @param string $value * @param string $type * @param string $listType * @return array|false [Matched value, attribute value that matched] */ public function checkValue($listValues, $value, $type, $listType) { if ($type === 'malware-sample' || strpos($type, '|') !== false) { $value = explode('|', $value, 2); } else { $value = array($value); } foreach ($value as $v) { if ($listType === 'cidr') { $result = $listValues->contains($v); } elseif ($listType === 'string') { $result = $this->__evalString($listValues, $v); } elseif ($listType === 'substring') { $result = $this->__evalSubString($listValues, $v); } elseif ($listType === 'hostname') { $result = $this->__evalHostname($listValues, $v); } elseif ($listType === 'regex') { $result = $this->__evalRegex($listValues, $v); } else { $result = false; } if ($result !== false) { return [$result, $v]; } } return false; } /** * Check for exact match. * * @param array $listValues * @param string $value * @return false */ private function __evalString($listValues, $value) { if (isset($listValues[$value])) { return $value; } return false; } private function __evalSubString($listValues, $value) { foreach ($listValues as $listValue) { if (strpos($value, $listValue) !== false) { return $listValue; } } return false; } private function __evalHostname($listValues, $value) { // php's parse_url is dumb, so let's use some hacky workarounds if (strpos($value, '//') === false) { $value = explode('/', $value); $hostname = $value[0]; } else { $value = explode('/', $value); $hostname = $value[2]; } // If the hostname is not found, just return false if (!isset($hostname)) { return false; } $hostname = rtrim($hostname, '.'); $hostname = explode('.', $hostname); $rebuilt = ''; foreach (array_reverse($hostname) as $piece) { if (empty($rebuilt)) { $rebuilt = $piece; } else { $rebuilt = $piece . '.' . $rebuilt; } if (isset($listValues[$rebuilt])) { return $rebuilt; } } return false; } private function __evalRegex($listValues, $value) { foreach ($listValues as $listValue) { if (preg_match($listValue, $value)) { return $listValue; } } return false; } /** * @return array */ public function fetchTLDLists() { $tldLists = $this->find('column', array( 'conditions' => array('Warninglist.name' => self::TLDS), 'fields' => array('Warninglist.id') )); $tlds = []; foreach ($tldLists as $warninglistId) { $tlds = array_merge($tlds, $this->getWarninglistEntries($warninglistId)); } $tlds = array_map('strtolower', $tlds); if (!in_array('onion', $tlds, true)) { $tlds[] = 'onion'; } return $tlds; } /** * @return array */ public function fetchSecurityVendorDomains() { $securityVendorList = $this->find('column', array( 'conditions' => array('Warninglist.name' => 'List of known domains used by automated malware analysis services & security vendors'), 'fields' => array('Warninglist.id') )); $domains = []; foreach ($securityVendorList as $warninglistId) { $domains = array_merge($domains, $this->getWarninglistEntries($warninglistId)); } return $domains; } /** * @param array $attribute * @param array|null $warninglists If null, all enabled warninglists will be used * @return bool */ public function filterWarninglistAttribute(array $attribute, $warninglists = null) { if ($warninglists === null) { $warninglists = $this->getEnabled(); } foreach ($warninglists as $warninglist) { if (in_array('ALL', $warninglist['types'], true) || in_array($attribute['type'], $warninglist['types'], true)) { $result = $this->checkValue($this->getFilteredEntries($warninglist), $attribute['value'], $attribute['type'], $warninglist['Warninglist']['type']); if ($result !== false) { return false; } } } return true; } public function missingTldLists() { $missingTldLists = array(); foreach (self::TLDS as $tldList) { $temp = $this->find('first', array( 'recursive' => -1, 'conditions' => array('Warninglist.name' => $tldList), 'fields' => array('Warninglist.id') )); if (empty($temp)) { $missingTldLists[] = $tldList; } } return $missingTldLists; } /** * @param null $data * @param bool $validate * @param array $fieldList * @return array|bool|mixed|null * @throws Exception */ public function save($data = null, $validate = true, $fieldList = array()) { $db = $this->getDataSource(); $transactionBegun = $db->begin(); $success = parent::save($data, $validate, $fieldList); if (empty($success)) { return false; } $db = $this->getDataSource(); try { $id = (int)$this->id; if (isset($data['WarninglistEntry'])) { $this->WarninglistEntry->deleteAll(['warninglist_id' => $id], false); $entriesToInsert = []; foreach ($data['WarninglistEntry'] as $entry) { $entriesToInsert[] = [$entry['value'], isset($entry['comment']) ? $entry['comment'] : null, $id]; } $db->insertMulti( $this->WarninglistEntry->table, ['value', 'comment', 'warninglist_id'], $entriesToInsert ); } if (isset($data['WarninglistType'])) { $this->WarninglistType->deleteAll(['warninglist_id' => $id], false); foreach ($data['WarninglistType'] as &$entry) { $entry['warninglist_id'] = $id; } $this->WarninglistType->saveMany($data['WarninglistType']); } if ($transactionBegun) { if ($success) { $db->commit(); } else { $db->rollback(); } } } catch (Exception $e) { if ($transactionBegun) { $db->rollback(); } throw $e; } if ($success) { $this->afterFullSave(!isset($data['Warninglist']['id']), $success); } return $success; } /** * @param bool $created * @return void */ private function afterFullSave($created, array $data) { if (isset($data['Warninglist']['default']) && $data['Warninglist']['default'] == 0) { $this->regenerateWarninglistCaches($data['Warninglist']['id']); } if ($this->pubToZmq('warninglist')) { $warninglist = $this->find('first', [ 'conditions' => ['id' => $data['Warninglist']['id']], 'contains' => ['WarninglistEntry', 'WarninglistType'], ]); $pubSubTool = $this->getPubSubTool(); $pubSubTool->warninglist_save($warninglist, $created ? 'add' : 'edit'); } } /** * @param string $input * @return array */ public function parseFreetext($input) { $input = trim($input); if (empty($input)) { return []; } $entries = []; foreach (explode("\n", trim($input)) as $entry) { $valueAndComment = explode("#", $entry, 2); $entries[] = [ 'value' => trim($valueAndComment[0]), 'comment' => count($valueAndComment) === 2 ? trim($valueAndComment[1]) : null, ]; } return $entries; } public function categories() { return [ self::CATEGORY_FALSE_POSITIVE => __('False positive'), self::CATEGORY_KNOWN => __('Known identifier'), ]; } }