Stop blocking secret references with no matching reference and imporve UI edit secret behavior to better highlight this

This commit is contained in:
Carlos Monastyrski
2025-09-09 10:34:00 -03:00
parent fe9ca7a3ee
commit 9a17cd6af0
3 changed files with 89 additions and 34 deletions
@@ -148,7 +148,7 @@ export const secretV2BridgeServiceFactory = ({
keyStore, keyStore,
reminderService reminderService
}: TSecretV2BridgeServiceFactoryDep) => { }: TSecretV2BridgeServiceFactoryDep) => {
const $validateSecretReferences = async ( const validateSecretReferences = async (
projectId: string, projectId: string,
permission: MongoAbility<ProjectPermissionSet>, permission: MongoAbility<ProjectPermissionSet>,
references: ReturnType<typeof getAllSecretReferences>["nestedReferences"], references: ReturnType<typeof getAllSecretReferences>["nestedReferences"],
@@ -312,12 +312,7 @@ export const secretV2BridgeServiceFactory = ({
project.secretDetectionIgnoreValues || [] project.secretDetectionIgnoreValues || []
); );
const { nestedReferences, localReferences } = getAllSecretReferences(inputSecret.secretValue); const { nestedReferences } = getAllSecretReferences(inputSecret.secretValue);
const allSecretReferences = nestedReferences.concat(
localReferences.map((el) => ({ secretKey: el, secretPath, environment }))
);
await $validateSecretReferences(projectId, permission, allSecretReferences);
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, 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) => const updatedSecret = await secretDAL.transaction(async (tx) =>
fnSecretBulkUpdate({ fnSecretBulkUpdate({
folderId, folderId,
@@ -1686,7 +1673,6 @@ export const secretV2BridgeServiceFactory = ({
}); });
} }
}); });
await $validateSecretReferences(projectId, permission, secretReferences);
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); 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); const project = await projectDAL.findById(projectId);
await scanSecretPolicyViolations( await scanSecretPolicyViolations(
@@ -3150,6 +3135,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretById, getSecretById,
getAccessibleSecrets, getAccessibleSecrets,
getSecretVersionsByIds, getSecretVersionsByIds,
findSecretIdsByFolderIdAndKeys findSecretIdsByFolderIdAndKeys,
validateSecretReferences
}; };
}; };
@@ -9,6 +9,15 @@ import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
import { SecretInput } from "../SecretInput"; 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) => { const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => {
// take substring up to pos in order to consider edits for closed references // take substring up to pos in order to consider edits for closed references
for (let i = pos; i >= 1; i -= 1) { for (let i = pos; i >= 1; i -= 1) {
@@ -83,7 +92,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
const [highlightedIndex, setHighlightedIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement>(null); const popoverContentRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useToggle(false); const [isFocused, setIsFocused] = useToggle(false);
const currentCursorPosition = inputRef.current?.selectionStart || 0; const currentCursorPosition = inputRef.current?.selectionStart || 0;
@@ -139,6 +148,8 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
} }
}); });
const allReferences = useMemo(() => extractReferences(value), [value]);
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
if (!isPopupOpen) return []; if (!isPopupOpen) return [];
// reset highlight whenever recomputation happens // reset highlight whenever recomputation happens
@@ -178,6 +189,44 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
return suggestionsArr; return suggestionsArr;
}, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]); }, [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<string>();
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 handleSuggestionSelect = (selectIndex?: number) => {
const selectedSuggestion = const selectedSuggestion =
suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex];
@@ -257,18 +306,20 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}; };
// to handle multiple ref for single component // to handle multiple ref for single component
const handleRef = useCallback((el: HTMLTextAreaElement) => { const handleRef = useCallback(
// @ts-expect-error this is for multiple ref single component (el: HTMLTextAreaElement) => {
inputRef.current = el; inputRef.current = el;
if (ref) { if (ref) {
if (typeof ref === "function") { if (typeof ref === "function") {
ref(el); ref(el);
} else { } else {
// eslint-disable-next-line // eslint-disable-next-line no-param-reassign
ref.current = el; ref.current = el;
}
} }
} },
}, []); [ref]
);
return ( return (
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}> <Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
@@ -287,6 +338,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}} }}
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
containerClassName={containerClassName} containerClassName={containerClassName}
invalidReferences={invalidReferences}
/> />
</Popover.Trigger> </Popover.Trigger>
<Popover.Content <Popover.Content
@@ -7,7 +7,12 @@ import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPa
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { const syntaxHighlight = (
content?: string | null,
isVisible?: boolean,
isImport?: boolean,
invalidReferences?: Set<string>
) => {
if (isImport && !content) return "IMPORTED"; if (isImport && !content) return "IMPORTED";
if (content === "") return "EMPTY"; if (content === "") return "EMPTY";
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("}"); const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
if (isInterpolationSyntax) { if (isInterpolationSyntax) {
skipNext = true; skipNext = true;
const referenceContent = el.slice(2, -1);
const isInvalid = invalidReferences?.has(referenceContent) ?? false;
return ( return (
<span className="ph-no-capture text-yellow" key={`secret-value-${i + 1}`}> <span
&#36;&#123;<span className="ph-no-capture text-yellow-200/80">{el.slice(2, -1)}</span> className={`ph-no-capture ${isInvalid ? "text-red-500" : "text-yellow"}`}
key={`secret-value-${i + 1}`}
>
&#36;&#123;
<span className={`ph-no-capture ${isInvalid ? "text-red-300/80" : "text-yellow-200/80"}`}>
{referenceContent}
</span>
&#125; &#125;
</span> </span>
); );
@@ -46,6 +60,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
isDisabled?: boolean; isDisabled?: boolean;
containerClassName?: string; containerClassName?: string;
canEditButNotView?: boolean; canEditButNotView?: boolean;
invalidReferences?: Set<string>;
}; };
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
@@ -63,6 +78,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isReadOnly, isReadOnly,
onFocus, onFocus,
canEditButNotView, canEditButNotView,
invalidReferences,
...props ...props
}, },
ref ref
@@ -81,7 +97,8 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
{syntaxHighlight( {syntaxHighlight(
value, value,
isVisible || (isSecretFocused && !valueAlwaysHidden), isVisible || (isSecretFocused && !valueAlwaysHidden),
isImport isImport,
invalidReferences
)} )}
</span> </span>
</code> </code>