From ac544c4b319bd100e980cc7a6d403b98f1fa2727 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 13 Mar 2025 18:19:51 -0700 Subject: [PATCH] Add Uri matching --- libs/common/src/vault/search/ast.ts | 25 ++++++- .../vault/search/bitwarden-query-grammar.ne | 6 ++ .../vault/search/bitwarden-query-grammar.ts | 36 ++++++++++ libs/common/src/vault/search/parse.ts | 68 +++++++++++++++++-- 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/libs/common/src/vault/search/ast.ts b/libs/common/src/vault/search/ast.ts index 73564852c2d..50d90769a87 100644 --- a/libs/common/src/vault/search/ast.ts +++ b/libs/common/src/vault/search/ast.ts @@ -17,6 +17,8 @@ export const AstNodeTypeNames = [ "inTrash", "isFavorite", "type", + "website", + "websiteMatch", ] as const; export type AstNodeType = (typeof AstNodeTypeNames)[number]; export type AstNode = @@ -37,7 +39,9 @@ export type AstNode = | InMyVault | InTrash | IsFavorite - | TypeFilter; + | TypeFilter + | WebsiteFilter + | WebsiteMatchFilter; type AstNodeBase = { d: object[]; @@ -203,3 +207,22 @@ export type TypeFilter = AstNodeBase & { export function isTypeFilter(x: AstNode): x is TypeFilter { return x.type === "type"; } + +export type WebsiteFilter = AstNodeBase & { + type: "website"; + website: string; +}; + +export function isWebsiteFilter(x: AstNode): x is WebsiteFilter { + return x.type === "website"; +} + +export type WebsiteMatchFilter = AstNodeBase & { + type: "websiteMatch"; + website: string; + matchType: string; +}; + +export function isWebsiteMatchFilter(x: AstNode): x is WebsiteMatchFilter { + return x.type === "websiteMatch"; +} diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ne b/libs/common/src/vault/search/bitwarden-query-grammar.ne index 42b11349474..fe5e2fc3d2f 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ne +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ne @@ -16,6 +16,7 @@ let lexer = moo.compile({ func_in: 'in:', func_is: 'is:', func_type: 'type:', + func_website: 'website:', // function parameter separator access: ':', // string match, includes quoted strings with escaped quotes and backslashes @@ -56,6 +57,7 @@ TERM -> | %func_in "collection" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inCollection', collection: d[3].value, d: d, start, end, length: end - start + 1 } } %} # only items assigned to a specified organization | %func_in "org" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inOrg', org: d[3].value, d: d, start, end, length: end - start + 1 } } %} + # only items in personal vault | %func_in "my_vault" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'inMyVault', d: d, start, end: start + length, length } } %} # only items in trash | %func_in "trash" {% function(d) { const start = d[0].offset; const length = 8; return { type: 'inTrash', d: d, start, end: start + length, length } } %} @@ -63,6 +65,10 @@ TERM -> | %func_is "favorite" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'isFavorite', d: d, start, end: start + length, length } } %} # only items of given type type | %func_type %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'type', d:d, cipherType: d[1].value, start, end, length: end - start + 1 } } %} + # only items with a specified website + | %func_website %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'website', d: d, website: d[1].value, start, end, length: end - start + 1 } } %} + # only items with a specified website and a given match pattern + | %func_website %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'websiteMatch', d: d, website: d[1].value, matchType: d[3].value, start, end, length: end - start + 1 } } %} # Boolean NOT operator | %NOT _ PARENTHESES {% function(d) { const start = d[0].offset; return { type: 'not', value: d[2], d: d, start, end: d[2].end, length: d[2].end - d[0].offset + 1 } } %} diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ts b/libs/common/src/vault/search/bitwarden-query-grammar.ts index f5547d9f6b5..48e34f5f89f 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ts +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ts @@ -15,6 +15,7 @@ declare var func_has: any; declare var func_in: any; declare var func_is: any; declare var func_type: any; +declare var func_website: any; declare var NOT: any; declare var WS: any; @@ -34,6 +35,7 @@ let lexer = moo.compile({ func_in: "in:", func_is: "is:", func_type: "type:", + func_website: "website:", // function parameter separator access: ":", // string match, includes quoted strings with escaped quotes and backslashes @@ -301,6 +303,40 @@ const grammar: Grammar = { return { type: "type", d: d, cipherType: d[1].value, start, end, length: end - start + 1 }; }, }, + { + name: "TERM", + symbols: [ + lexer.has("func_website") ? { type: "func_website" } : func_website, + lexer.has("string") ? { type: "string" } : string, + ], + postprocess: function (d) { + const start = d[0].offset; + const end = d[1].offset + d[1].value.length; + return { type: "website", d: d, website: d[1].value, start, end, length: end - start + 1 }; + }, + }, + { + name: "TERM", + symbols: [ + lexer.has("func_website") ? { type: "func_website" } : func_website, + lexer.has("string") ? { type: "string" } : string, + lexer.has("access") ? { type: "access" } : access, + lexer.has("string") ? { type: "string" } : string, + ], + postprocess: function (d) { + const start = d[0].offset; + const end = d[3].offset + d[3].value.length; + return { + type: "websiteMatch", + d: d, + website: d[1].value, + matchType: d[3].value, + start, + end, + length: end - start + 1, + }; + }, + }, { name: "TERM", symbols: [lexer.has("NOT") ? { type: "NOT" } : NOT, "_", "PARENTHESES"], diff --git a/libs/common/src/vault/search/parse.ts b/libs/common/src/vault/search/parse.ts index 7e1ed604f14..3476a0f6f6f 100644 --- a/libs/common/src/vault/search/parse.ts +++ b/libs/common/src/vault/search/parse.ts @@ -1,5 +1,6 @@ import { Parser, Grammar } from "nearley"; +import { UriMatchStrategy } from "../../models/domain/domain-service"; import { Utils } from "../../platform/misc/utils"; import { CardLinkedId, CipherType, FieldType, LinkedIdType, LoginLinkedId } from "../enums"; import { CipherView } from "../models/view/cipher.view"; @@ -23,6 +24,8 @@ import { isSearch, isTerm, isTypeFilter, + isWebsiteFilter, + isWebsiteMatchFilter, } from "./ast"; import grammar from "./bitwarden-query-grammar"; import { ProcessInstructions } from "./query.types"; @@ -315,10 +318,48 @@ function handleNode(node: AstNode): ProcessInstructions { return { filter: (context) => ({ ...context, - ciphers: context.ciphers.filter( - (cipher) => - typeTest.test(CipherType[cipher.type]) || - CipherType[node.cipherType as any] === CipherType[cipher.type], + ciphers: context.ciphers.filter((cipher) => + matchEnum(CipherType, cipher.type, typeTest, node.cipherType), + ), + }), + sections: [ + { + start: node.start, + end: node.end, + type: node.type, + }, + ], + }; + } else if (isWebsiteFilter(node)) { + const websiteTest = termToRegexTest(node.website); + return { + filter: (context) => ({ + ...context, + ciphers: context.ciphers.filter((cipher) => + cipher?.login?.uris?.some((uri) => websiteTest.test(uri.uri)), + ), + }), + sections: [ + { + start: node.start, + end: node.end, + type: node.type, + }, + ], + }; + } else if (isWebsiteMatchFilter(node)) { + const websiteTest = termToRegexTest(node.website); + const matchTest = fieldNameToRegexTest(node.matchType); + const matchTypes = Object.keys(UriMatchStrategy) + .filter((key) => matchTest.test(key)) + .map((key) => UriMatchStrategy[key as keyof typeof UriMatchStrategy]); + return { + filter: (context) => ({ + ...context, + ciphers: context.ciphers.filter((cipher) => + cipher?.login?.uris?.some( + (uri) => matchTypes.includes(uri.match) && websiteTest.test(uri.uri), + ), ), }), sections: [ @@ -334,6 +375,25 @@ function handleNode(node: AstNode): ProcessInstructions { } } +/** + * Match a string against an enum value. The matching string is sent in twice to match the enum in both directions, + * number -> string and string -> number. + * + * @param enumObj The Enum type + * @param cipherVal The existing value on a cipher to test for a match + * @param valTest The regex test to apply to cipherVal + * @param targetValue The raw value to test against the cipherVal + * @returns + */ +function matchEnum( + enumObj: { [name: string]: any }, + cipherVal: string | number, + valTest: RegExp, + targetValue: string, +) { + return valTest.test(enumObj[cipherVal]) || enumObj[targetValue] === enumObj[cipherVal]; +} + function hasTerm(cipher: CipherView, termTest: RegExp, fieldTest: RegExp = /.*/i): boolean { const foundValues = fieldValues(cipher, fieldTest);