From 9a17cd6af097ac26bc3a4bf1f7a45d300b8da23b Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 9 Sep 2025 10:34:00 -0300 Subject: [PATCH] Stop blocking secret references with no matching reference and imporve UI edit secret behavior to better highlight this --- .../secret-v2-bridge-service.ts | 22 +----- .../InfisicalSecretInput.tsx | 76 ++++++++++++++++--- .../components/v2/SecretInput/SecretInput.tsx | 25 +++++- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 48ab07816..7ad8ab82c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -148,7 +148,7 @@ export const secretV2BridgeServiceFactory = ({ keyStore, reminderService }: TSecretV2BridgeServiceFactoryDep) => { - const $validateSecretReferences = async ( + const validateSecretReferences = async ( projectId: string, permission: MongoAbility, references: ReturnType["nestedReferences"], @@ -312,12 +312,7 @@ export const secretV2BridgeServiceFactory = ({ project.secretDetectionIgnoreValues || [] ); - const { nestedReferences, localReferences } = getAllSecretReferences(inputSecret.secretValue); - const allSecretReferences = nestedReferences.concat( - localReferences.map((el) => ({ secretKey: el, secretPath, environment })) - ); - - await $validateSecretReferences(projectId, permission, allSecretReferences); + const { nestedReferences } = getAllSecretReferences(inputSecret.secretValue); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -558,14 +553,6 @@ export const secretV2BridgeServiceFactory = ({ } : {}; - if (secretValue) { - const { nestedReferences, localReferences } = getAllSecretReferences(secretValue); - const allSecretReferences = nestedReferences.concat( - localReferences.map((el) => ({ secretKey: el, secretPath, environment })) - ); - await $validateSecretReferences(projectId, permission, allSecretReferences); - } - const updatedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -1686,7 +1673,6 @@ export const secretV2BridgeServiceFactory = ({ }); } }); - await $validateSecretReferences(projectId, permission, secretReferences); const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); @@ -2000,7 +1986,6 @@ export const secretV2BridgeServiceFactory = ({ }); } }); - await $validateSecretReferences(projectId, permission, secretReferences, tx); const project = await projectDAL.findById(projectId); await scanSecretPolicyViolations( @@ -3150,6 +3135,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretById, getAccessibleSecrets, getSecretVersionsByIds, - findSecretIdsByFolderIdAndKeys + findSecretIdsByFolderIdAndKeys, + validateSecretReferences }; }; diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index ee2f196b1..c4b438e9a 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -9,6 +9,15 @@ import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; import { SecretInput } from "../SecretInput"; +// Regex to find all secret references in the format ${reference} +const REFERENCE_REGEX = /\${([^}]+)}/g; + +// Extract unique references from a value +const extractReferences = (value: string): string[] => { + const matches = Array.from(value.matchAll(REFERENCE_REGEX)); + return [...new Set(matches.map((match) => match[1]))]; +}; + const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => { // take substring up to pos in order to consider edits for closed references for (let i = pos; i >= 1; i -= 1) { @@ -83,7 +92,7 @@ export const InfisicalSecretInput = forwardRef( const [highlightedIndex, setHighlightedIndex] = useState(-1); - const inputRef = useRef(null); + const inputRef = useRef(null); const popoverContentRef = useRef(null); const [isFocused, setIsFocused] = useToggle(false); const currentCursorPosition = inputRef.current?.selectionStart || 0; @@ -139,6 +148,8 @@ export const InfisicalSecretInput = forwardRef( } }); + const allReferences = useMemo(() => extractReferences(value), [value]); + const suggestions = useMemo(() => { if (!isPopupOpen) return []; // reset highlight whenever recomputation happens @@ -178,6 +189,44 @@ export const InfisicalSecretInput = forwardRef( return suggestionsArr; }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]); + // Mark as invalid when editing and reference doesn't match any suggestion + const invalidReferences = useMemo(() => { + const invalid = new Set(); + + if (!isPopupOpen) { + return invalid; // No validation when not editing + } + + const suggestionsHaveLoaded = Boolean(secrets || folders || currentWorkspace?.environments); + + if (!suggestionsHaveLoaded) { + return invalid; + } + + // If we have an active suggestion context but no suggestions, it means the query returned empty + const suggestionsAreEmpty = suggestions.length === 0; + + allReferences.forEach((reference) => { + const matchesAnySuggestion = suggestions.some((suggestion) => { + if (!reference.includes(".")) { + return suggestion.slug === reference; + } + const parts = reference.split("."); + const finalPart = parts[parts.length - 1]; + return suggestion.slug === finalPart; + }); + + // Mark as invalid only if: + // 1. We have suggestions loaded AND none match, OR + // 2. The query returned empty results + if (!matchesAnySuggestion && (suggestions.length > 0 || suggestionsAreEmpty)) { + invalid.add(reference); + } + }); + + return invalid; + }, [isPopupOpen, allReferences, suggestions, secrets, folders, currentWorkspace?.environments]); + const handleSuggestionSelect = (selectIndex?: number) => { const selectedSuggestion = suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; @@ -257,18 +306,20 @@ export const InfisicalSecretInput = forwardRef( }; // to handle multiple ref for single component - const handleRef = useCallback((el: HTMLTextAreaElement) => { - // @ts-expect-error this is for multiple ref single component - inputRef.current = el; - if (ref) { - if (typeof ref === "function") { - ref(el); - } else { - // eslint-disable-next-line - ref.current = el; + const handleRef = useCallback( + (el: HTMLTextAreaElement) => { + inputRef.current = el; + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + // eslint-disable-next-line no-param-reassign + ref.current = el; + } } - } - }, []); + }, + [ref] + ); return ( @@ -287,6 +338,7 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} + invalidReferences={invalidReferences} /> { +const syntaxHighlight = ( + content?: string | null, + isVisible?: boolean, + isImport?: boolean, + invalidReferences?: Set +) => { if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; @@ -18,9 +23,18 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport? const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; + const referenceContent = el.slice(2, -1); + const isInvalid = invalidReferences?.has(referenceContent) ?? false; + return ( - - ${{el.slice(2, -1)} + + ${ + + {referenceContent} + } ); @@ -46,6 +60,7 @@ type Props = TextareaHTMLAttributes & { isDisabled?: boolean; containerClassName?: string; canEditButNotView?: boolean; + invalidReferences?: Set; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -63,6 +78,7 @@ export const SecretInput = forwardRef( isReadOnly, onFocus, canEditButNotView, + invalidReferences, ...props }, ref @@ -81,7 +97,8 @@ export const SecretInput = forwardRef( {syntaxHighlight( value, isVisible || (isSecretFocused && !valueAlwaysHidden), - isImport + isImport, + invalidReferences )}