/* * Copyright (C) 2014 Florian Geyer * Copyright (C) 2017 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 or (at your option) * version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "EntrySearcher.h" #include "PasswordHealth.h" #include "core/Group.h" #include "core/Tools.h" EntrySearcher::EntrySearcher(bool caseSensitive, bool skipProtected) : m_caseSensitive(caseSensitive) , m_skipProtected(skipProtected) { } /** * Search group, and its children, directly by provided search terms * @param searchTerms search terms * @param baseGroup group to start search from, cannot be null * @param forceSearch ignore group search settings * @return list of entries that match the search terms */ QList EntrySearcher::search(const QList& searchTerms, const Group* baseGroup, bool forceSearch) { Q_ASSERT(baseGroup); m_searchTerms = searchTerms; return repeat(baseGroup, forceSearch); } /** * Search group, and its children, by parsing the provided search * string for search terms. * * @param searchString search terms * @param baseGroup group to start search from, cannot be null * @param forceSearch ignore group search settings * @return list of entries that match the search terms */ QList EntrySearcher::search(const QString& searchString, const Group* baseGroup, bool forceSearch) { Q_ASSERT(baseGroup); parseSearchTerms(searchString); return repeat(baseGroup, forceSearch); } /** * Repeat the last search starting from the given group * * @param baseGroup group to start search from, cannot be null * @param forceSearch ignore group search settings * @return list of entries that match the search terms */ QList EntrySearcher::repeat(const Group* baseGroup, bool forceSearch) { Q_ASSERT(baseGroup); QList results; for (const auto group : baseGroup->groupsRecursive(true)) { if (forceSearch || group->resolveSearchingEnabled()) { for (const auto entry : group->entries()) { if (searchEntryImpl(entry)) { results.append(entry); } } } } return results; } /** * Search provided entries by the provided search terms * * @param searchTerms search terms * @param entries list of entries to include in the search * @return list of entries that match the search terms */ QList EntrySearcher::searchEntries(const QList& searchTerms, const QList& entries) { m_searchTerms = searchTerms; return repeatEntries(entries); } /** * Search provided entries by parsing the search string * for search terms. * * @param searchString search terms * @param entries list of entries to include in the search * @return list of entries that match the search terms */ QList EntrySearcher::searchEntries(const QString& searchString, const QList& entries) { parseSearchTerms(searchString); return repeatEntries(entries); } /** * Repeat the last search on the given entries * * @param entries list of entries to include in the search * @return list of entries that match the search terms */ QList EntrySearcher::repeatEntries(const QList& entries) { QList results; for (auto* entry : entries) { if (searchEntryImpl(entry)) { results.append(entry); } } return results; } /** * Set the next search to be case sensitive or not * * @param state */ void EntrySearcher::setCaseSensitive(bool state) { m_caseSensitive = state; } bool EntrySearcher::isCaseSensitive() const { return m_caseSensitive; } bool EntrySearcher::searchEntryImpl(const Entry* entry) { // Pre-load in case they are needed auto attributes_keys = entry->attributes()->customKeys(); auto attributes = QStringList(attributes_keys + entry->attributes()->values(attributes_keys)); auto attachments = QStringList(entry->attachments()->keys()); // Build a group hierarchy to allow searching for e.g. /group1/subgroup* QString hierarchy; if (entry->group()) { hierarchy = entry->group()->hierarchy().join('/').prepend("/"); } // By default, empty term matches every entry. // However when skipping protected fields, we will reject everything instead bool found = !m_skipProtected; for (const auto& term : m_searchTerms) { switch (term.field) { case Field::Title: found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch(); break; case Field::Username: found = term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch(); break; case Field::Password: if (m_skipProtected) { continue; } found = term.regex.match(entry->resolvePlaceholder(entry->password())).hasMatch(); break; case Field::Url: found = term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch(); break; case Field::Notes: found = term.regex.match(entry->notes()).hasMatch(); break; case Field::AttributeKV: found = !attributes.filter(term.regex).empty(); break; case Field::Attachment: found = !attachments.filter(term.regex).empty(); break; case Field::AttributeValue: if (m_skipProtected && entry->attributes()->isProtected(term.word)) { continue; } found = entry->attributes()->contains(term.word) && term.regex.match(entry->attributes()->value(term.word)).hasMatch(); break; case Field::Group: // Match against the full hierarchy if the word contains a '/' otherwise just the group name if (term.word.contains('/')) { found = term.regex.match(hierarchy).hasMatch(); } else if (entry->group()) { found = term.regex.match(entry->group()->name()).hasMatch(); } break; case Field::Tag: found = entry->tagList().indexOf(term.regex) != -1; break; case Field::Is: if (term.word.startsWith("expired", Qt::CaseInsensitive)) { auto days = 0; auto parts = term.word.split("-", QString::SkipEmptyParts); if (parts.length() >= 2) { days = parts[1].toInt(); } found = entry->willExpireInDays(days) && !entry->isRecycled(); break; } else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) { if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) { const auto quality = entry->passwordHealth()->quality(); if (quality == PasswordHealth::Quality::Bad || quality == PasswordHealth::Quality::Poor || quality == PasswordHealth::Quality::Weak) { found = true; break; } } } found = false; break; case Field::Uuid: found = term.regex.match(entry->uuidToHex()).hasMatch(); break; default: // Terms without a specific field try to match title, username, url, and notes found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch() || term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch() || term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch() || entry->tagList().indexOf(term.regex) != -1 || term.regex.match(entry->notes()).hasMatch(); } // negate the result if exclude: // * if found and not excluding, the entry matches // * if didn't found but excluding, the entry also matches found = (found && !term.exclude) || (!found && term.exclude); // short circuit if we failed the match if (!found) { return false; } } return found; } void EntrySearcher::parseSearchTerms(const QString& searchString) { static const QList> fieldnames{ {QStringLiteral("attachment"), Field::Attachment}, {QStringLiteral("attribute"), Field::AttributeKV}, {QStringLiteral("notes"), Field::Notes}, {QStringLiteral("pw"), Field::Password}, {QStringLiteral("password"), Field::Password}, {QStringLiteral("title"), Field::Title}, // title before tag to capture t: {QStringLiteral("username"), Field::Username}, // username before url to capture u: {QStringLiteral("url"), Field::Url}, {QStringLiteral("group"), Field::Group}, {QStringLiteral("tag"), Field::Tag}, {QStringLiteral("is"), Field::Is}, {QStringLiteral("uuid"), Field::Uuid}}; // Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string static QRegularExpression termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re"); m_searchTerms.clear(); auto results = termParser.globalMatch(searchString); while (results.hasNext()) { auto result = results.next(); SearchTerm term{}; // Quoted string group term.word = result.captured(3); // Unescape quotes term.word.replace("\\\"", "\""); // If empty, use the unquoted string group if (term.word.isEmpty()) { term.word = result.captured(4); } // If still empty, ignore this match if (term.word.isEmpty()) { continue; } auto mods = result.captured(1); // Convert term to regex int opts = m_caseSensitive ? Tools::RegexConvertOpts::CASE_SENSITIVE : Tools::RegexConvertOpts::DEFAULT; if (!mods.contains("*")) { opts |= Tools::RegexConvertOpts::WILDCARD_ALL; } if (mods.contains("+")) { opts |= Tools::RegexConvertOpts::EXACT_MATCH; } term.regex = Tools::convertToRegex(term.word, opts); // Exclude modifier term.exclude = mods.contains("-") || mods.contains("!"); // Determine the field to search term.field = Field::Undefined; QString field = result.captured(2); if (!field.isEmpty()) { if (field.startsWith("_", Qt::CaseInsensitive)) { term.field = Field::AttributeValue; // searching a custom attribute // in this case term.word is the attribute key (removing the leading "_") // and term.regex is used to match attribute value term.word = field.mid(1); } else { for (const auto& pair : fieldnames) { if (pair.first.startsWith(field, Qt::CaseInsensitive)) { term.field = pair.second; break; } } } } m_searchTerms.append(term); } }