diff --git a/package.json b/package.json index b509dae..031ad75 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,16 @@ "command": "op-vscode.openLogs", "title": "Open logs", "category": "1Password" + }, + { + "command": "op-vscode.ignorePattern", + "title": "Ignore pattern", + "category": "1Password" + }, + { + "command": "op-vscode.createCustomPattern", + "title": "Create custom pattern", + "category": "1Password" } ], "configuration": [ @@ -129,6 +139,38 @@ "type": "boolean", "default": false, "description": "Log debugger data. Reload required." + }, + "1password.patterns.disabled": { + "order": 6, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Patterns that have been disabled from being suggested." + }, + "1password.patterns.custom": { + "order": 7, + "type": "array", + "items": { + "type": "object", + "properties": { + "item": { + "type": ["string", "null"], + "description": "The name associated with the pattern." + }, + "field": { + "type": ["string", "null"], + "description": "The field that the pattern detects (e.g., password, API token...)." + }, + "pattern": { + "type": "string", + "description": "The pattern to detect." + } + } + }, + "default": [], + "description": "User-made patterns to be detected and suggested." } } } diff --git a/src/configuration.ts b/src/configuration.ts index 1a61952..82bf4d1 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -8,6 +8,8 @@ export enum ConfigKey { ItemsUseSecretReferences = "items.useSecretReferences", EditorSuggestStorage = "editor.suggestStorage", DebugEnabled = "debug.enabled", + PatternsDisabled = "patterns.disabled", + PatternsCustom = "patterns.custom", } interface ConfigItems { @@ -16,6 +18,8 @@ interface ConfigItems { [ConfigKey.ItemsUseSecretReferences]: boolean; [ConfigKey.EditorSuggestStorage]: boolean; [ConfigKey.DebugEnabled]: boolean; + [ConfigKey.PatternsDisabled]: string[]; + [ConfigKey.PatternsCustom]: object[]; } class Config { diff --git a/src/constants.ts b/src/constants.ts index d5a8773..9c486b6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,6 +15,8 @@ export const COMMANDS = { INJECT_SECRETS: makeCommand("injectSecrets"), CREATE_PASSWORD: makeCommand("createPassword"), OPEN_LOGS: makeCommand("openLogs"), + IGNORE_PATTERN: makeCommand("ignorePattern"), + CREATE_CUSTOM_PATTERN: makeCommand("createCustomPattern"), }; // This is only internal in that it is not exposed to the diff --git a/src/core.ts b/src/core.ts index 2356021..7cb3c02 100644 --- a/src/core.ts +++ b/src/core.ts @@ -9,6 +9,7 @@ import { Items } from "./items"; import { logger } from "./logger"; import { Setup } from "./setup"; import { createOpenOPHandler, OpvsUriHandler } from "./url-utils"; +import { patterns } from "./secret-detection/patterns"; export class Core { public cli: CLI; @@ -26,6 +27,14 @@ export class Core { commands.registerCommand(INTERNAL_COMMANDS.AUTHENTICATE, async () => this.authenticate(), ), + commands.registerCommand( + COMMANDS.IGNORE_PATTERN, + async (id) => await patterns.disablePattern(id), + ), + commands.registerCommand( + COMMANDS.CREATE_CUSTOM_PATTERN, + async () => await patterns.addCustomPattern(), + ), ); this.cli = new CLI(); diff --git a/src/language-providers/code-lens.test.ts b/src/language-providers/code-lens.test.ts index 1156153..9a693c9 100644 --- a/src/language-providers/code-lens.test.ts +++ b/src/language-providers/code-lens.test.ts @@ -5,6 +5,7 @@ import GenericParser, * as genericParser from "../secret-detection/parsers/gener import JsonParser, * as jsonParser from "../secret-detection/parsers/json"; import YamlParser, * as yamlParser from "../secret-detection/parsers/yaml"; import { documentMatcher, provideCodeLenses } from "./code-lens"; +import { patterns } from "../secret-detection/patterns"; describe("documentMatcher", () => { const languageDocument = createDocument([], "properties", "test.js"); @@ -33,6 +34,28 @@ describe("provideCodeLenses", () => { expect(codeLenses).toBeUndefined(); }); + it("retrieves custom patterns", () => { + const getCustomPatternsSpy = jest + .spyOn(patterns, "getCustomPatterns") + .mockReturnValue([]); + jest.spyOn(config, "get").mockReturnValue(true); + provideCodeLenses(createDocument([])); + expect(getCustomPatternsSpy).toHaveBeenCalled(); + }); + + it("ignores disabled patterns", () => { + jest.spyOn(patterns, "getDisabledPatterns").mockReturnValue(["ccard"]); + jest.spyOn(config, "get").mockReturnValue(true); + const codeLenses = provideCodeLenses( + createDocument([ + "Visa 4012888888881881", + "MasterCard 5555555555554444", + "Amex 371449635398431", + ]), + ); + expect(codeLenses).toHaveLength(0); + }); + it("uses the generic parser for an unmatched language", () => { const genericParserSpy = jest .spyOn(genericParser, "default") diff --git a/src/language-providers/code-lens.ts b/src/language-providers/code-lens.ts index 63e73c4..c555981 100644 --- a/src/language-providers/code-lens.ts +++ b/src/language-providers/code-lens.ts @@ -7,6 +7,8 @@ import DotEnvParser from "../secret-detection/parsers/dotenv"; import GenericParser from "../secret-detection/parsers/generic"; import JsonParser from "../secret-detection/parsers/json"; import YamlParser from "../secret-detection/parsers/yaml"; +import { PatternSuggestion } from "../secret-detection/suggestion"; +import { patterns } from "../secret-detection/patterns"; export const documentMatcher = (document: TextDocument) => (ids: string[], exts: string[]) => @@ -31,19 +33,47 @@ export const provideCodeLenses = (document: TextDocument): CodeLens[] => { parser = new GenericParser(document); } - return parser + const matches = parser .getMatches() .filter( // Ignore values within secret template variables ({ range, fieldValue, suggestion }) => !new RegExp(/\${{(.*?)}}/).test(fieldValue), ) - .map( + .filter((match) => patterns.patternsFilter(match.suggestion)); + + const customPatternsResult: PatternSuggestion[] = + patterns.getCustomPatterns(); + const customPatterns: string[] = Array.isArray(customPatternsResult) + ? customPatternsResult.map((suggestion) => suggestion.pattern) + : []; + + return [ + ...matches.map( ({ range, fieldValue, suggestion }) => new CodeLens(range, { title: "$(lock) Save in 1Password", command: COMMANDS.SAVE_VALUE_TO_ITEM, arguments: [[{ location: range, fieldValue, suggestion }]], }), - ); + ), + ...matches + // Don't give the option to ignore custom patterns, + // as they can just be deleted from the settings.json file. + .filter( + (match) => + match.suggestion !== undefined && + !customPatterns.includes( + (match.suggestion as PatternSuggestion).pattern, + ), + ) + .map( + ({ range, fieldValue, suggestion }) => + new CodeLens(range, { + title: "Ignore pattern", + command: COMMANDS.IGNORE_PATTERN, + arguments: [(suggestion as PatternSuggestion).id], + }), + ), + ]; }; diff --git a/src/secret-detection/parsers/index.test.ts b/src/secret-detection/parsers/index.test.ts index 01e3fa3..26384d4 100644 --- a/src/secret-detection/parsers/index.test.ts +++ b/src/secret-detection/parsers/index.test.ts @@ -5,7 +5,7 @@ import { validValueIsolation, } from "."; import { sample } from "../../../test/utils"; -import { getPatternSuggestion } from "../patterns"; +import { patterns, getPatternSuggestion } from "../patterns"; import { BRANDS } from "../suggestion"; describe("findBrand", () => { @@ -68,6 +68,14 @@ describe("matchFromRegexp", () => { suggestion, }); }); + + it("retrieves custom patterns", () => { + const customPatternsSpy = jest + .spyOn(patterns, "getCustomPatterns") + .mockReturnValue([]); + matchFromRegexp("test"); + expect(customPatternsSpy).toHaveBeenCalled(); + }); }); describe("suggestionFromKey", () => { diff --git a/src/secret-detection/parsers/index.ts b/src/secret-detection/parsers/index.ts index 1d1c596..07128c5 100644 --- a/src/secret-detection/parsers/index.ts +++ b/src/secret-detection/parsers/index.ts @@ -1,8 +1,13 @@ import { FieldAssignmentType } from "@1password/op-js"; import { Range, TextDocument } from "vscode"; import { combineRegexp } from "../../utils"; -import { getPatternSuggestion, VALUE_PATTERNS } from "../patterns"; -import { BRANDS, SECRET_KEY_HINT, Suggestion } from "../suggestion"; +import { patterns, getPatternSuggestion, VALUE_PATTERNS } from "../patterns"; +import { + BRANDS, + PatternSuggestion, + SECRET_KEY_HINT, + Suggestion, +} from "../suggestion"; export interface ParserMatch { range: Range; @@ -35,6 +40,7 @@ export const patternSuggestions = [ ...VALUE_PATTERNS, getPatternSuggestion("ccard"), ]; + const patternsRegex = combineRegexp( ...patternSuggestions.map((detection) => new RegExp(detection.pattern)), ); @@ -66,7 +72,12 @@ export const matchFromRegexp = ( input: string, partial = false, ): MatchDetail | undefined => { - const patternMatch = patternsRegex.exec(input); + const customPatterns: PatternSuggestion[] = patterns.getCustomPatterns(); + const allPatternsRegex = combineRegexp( + patternsRegex, + ...customPatterns.map((suggestion) => new RegExp(suggestion.pattern)), + ); + const patternMatch = allPatternsRegex.exec(input); if (!patternMatch) { return; } @@ -84,7 +95,8 @@ export const matchFromRegexp = ( // We know that the value matches one of the patterns, // now let's find out which one - for (const patternSuggestion of patternSuggestions) { + const allPatternSuggestions = [...patternSuggestions, ...customPatterns]; + for (const patternSuggestion of allPatternSuggestions) { if (new RegExp(patternSuggestion.pattern).test(value)) { suggestion = patternSuggestion; diff --git a/src/secret-detection/patterns.test.ts b/src/secret-detection/patterns.test.ts index 20505ef..41bd258 100644 --- a/src/secret-detection/patterns.test.ts +++ b/src/secret-detection/patterns.test.ts @@ -1,5 +1,10 @@ import testData from "./pattern-test-data.json"; -import { FIELD_TYPE_PATTERNS, getPatternSuggestion } from "./patterns"; +import { + FIELD_TYPE_PATTERNS, + getPatternSuggestion, + patterns, +} from "./patterns"; +import { ConfigKey, config } from "../configuration"; describe("getPatternSuggestion", () => { it("should return a pattern suggestion", () => { @@ -69,3 +74,24 @@ describe("VALUE_PATTERNS", () => { expect(value).toMatchRegExp(new RegExp(patternSuggestion.pattern)); }); }); + +describe("patterns", () => { + describe("getDisabledPatterns", () => { + it("should return an empty array if no disabled patterns are set", () => { + expect(patterns.getDisabledPatterns()).toEqual([]); + }); + }); + describe("getCustomPatterns", () => { + it("should return an empty array if no custom patterns are set", () => { + expect(patterns.getCustomPatterns()).toEqual([]); + }); + }); + describe("patternsFilter", () => { + it("should filter out disabled patterns", () => { + jest.spyOn(patterns, "getDisabledPatterns").mockReturnValue(["ccard"]); + expect( + patterns.patternsFilter(getPatternSuggestion("ccard")), + ).toBeFalsy(); + }); + }); +}); diff --git a/src/secret-detection/patterns.ts b/src/secret-detection/patterns.ts index a816de8..4abe2f1 100644 --- a/src/secret-detection/patterns.ts +++ b/src/secret-detection/patterns.ts @@ -1,4 +1,68 @@ -import { PatternSuggestion } from "./suggestion"; +import { PatternSuggestion, Suggestion } from "./suggestion"; +import { window } from "vscode"; +import { config, ConfigKey } from "../configuration"; + +export class Patterns { + public getCustomPatterns(): PatternSuggestion[] { + return config.get(ConfigKey.PatternsCustom) || []; + } + + public getDisabledPatterns(): string[] { + return config.get(ConfigKey.PatternsDisabled) || []; + } + + public async disablePattern(id: string): Promise { + const newDisabledPatterns = [...this.getDisabledPatterns(), id]; + await config.set(ConfigKey.PatternsDisabled, newDisabledPatterns).then( + () => window.showInformationMessage(`Pattern ${id} disabled.`), + () => window.showErrorMessage(`Could not disable pattern.`), + ); + } + + public async addCustomPattern(): Promise { + const regex = await window.showInputBox({ + title: "Enter a regex pattern.", + ignoreFocusOut: true, + }); + + if (!regex || regex.length === 0) { + return; + } + + const item = await window.showInputBox({ + title: "Enter an item name (optional).", + ignoreFocusOut: true, + }); + + const field = await window.showInputBox({ + title: "Enter a field name (optional).", + ignoreFocusOut: true, + }); + + const newPattern = { + item, + field, + pattern: regex, + }; + + const customPatterns = [...this.getCustomPatterns(), newPattern]; + await config.set(ConfigKey.PatternsCustom, customPatterns).then( + () => window.showInformationMessage(`Custom pattern added`), + () => window.showErrorMessage(`Could not add custom pattern.`), + ); + } + + public patternsFilter(suggestion: Suggestion) { + // If the suggestion is not a PatternSuggestion or the suggestion is not disabled + return ( + suggestion === undefined || + (suggestion as PatternSuggestion).id === undefined || + !this.getDisabledPatterns().includes((suggestion as PatternSuggestion).id) + ); + } +} + +export const patterns = new Patterns(); export const getPatternSuggestion = (id: string): PatternSuggestion => [...FIELD_TYPE_PATTERNS, ...VALUE_PATTERNS].find( @@ -248,4 +312,5 @@ export const VALUE_PATTERNS: PatternSuggestion[] = [ pattern: "https://chat.twilio.com/v2/Services/[A-Z0-9]{32}", }, ]; + /* eslint-enable sonarjs/no-duplicate-string */