Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ export default class Phase {
);

// Fetcher for resolving references
const fetcher: SecretFetcher = async (envName, path, key) => {
const cacheKey = normalizeKey(envName, path, key);
const fetcher: SecretFetcher = async (envName, path, key, appName) => {
const cacheKey = normalizeKey(envName, path, key, appName || undefined);

if (cache.has(cacheKey)) {
return {
Expand All @@ -222,18 +222,51 @@ export default class Phase {

let secret = secretLookup.get(cacheKey);
if (!secret) {
const crossEnvSecrets = await this.get({
...options,
envName,
path,
key,
tags: undefined,
});
secret = crossEnvSecrets.find((s) => s.key === key);
if (!secret)
throw new Error(`Missing secret: ${envName}:${path}:${key}`);

secretLookup.set(cacheKey, secret);
try {
// For cross-app references, find the target app ID
let targetAppId = options.appId;
if (appName) {
// Check if appName might be an ID first
const appById = this.apps.find(a => a.id === appName);
if (appById) {
targetAppId = appName; // It was an ID, use it directly
} else {
// Treat appName as a name and check for duplicates
const matchingApps = this.apps.filter(a => a.name === appName);

if (matchingApps.length === 0) {
// No app found by ID or name
throw new Error(`App not found: '${appName}'. Please check the app name or ID and ensure your token has access.`);
} else if (matchingApps.length > 1) {
// Found multiple apps with the same name - ambiguous!
const appDetails = matchingApps.map(a => `'${a.name}' (ID: ${a.id})`).join(', ');
throw new Error(`Ambiguous app name: '${appName}'. Multiple apps found: ${appDetails}.`);
} else {
// Found exactly one app by name
targetAppId = matchingApps[0].id;
}
}
}

// Fetch the secret from the target app/environment
const crossEnvSecrets = await this.get({
appId: targetAppId,
envName,
path,
key,
tags: undefined,
});

secret = crossEnvSecrets.find(s => s.key === key);
if (!secret) {
throw new Error(`Secret not found: ${key} in ${envName}${path !== '/' ? `, path ${path}` : ''}${appName ? `, app ${appName}` : ''}`);
}

secretLookup.set(cacheKey, secret);
} catch (error: any) {
const msg = error.message || String(error);
throw new Error(`Failed to resolve reference: ${msg}`);
}
}

cache.set(cacheKey, secret.value);
Expand All @@ -249,6 +282,7 @@ export default class Phase {
options.envName,
options.path || "/",
fetcher,
null,
cache
),
}))
Expand Down
69 changes: 50 additions & 19 deletions src/utils/secretReferencing.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,117 @@
import { Secret } from "../types";

type SecretReference = {
app: string | null;
env: string | null;
path: string | null;
path: string;
key: string;
};

export type SecretFetcher = (
env: string,
path: string,
key: string
key: string,
app?: string | null
) => Promise<Secret>;

// Regex pattern for secret references
const REFERENCE_REGEX =
/\${(?:(?<env>[^.\/}]+)\.)?(?:(?<path>[^}]+)\/)?(?<key>[^}]+)}/g;
/\${(?:(?<app>[^:}]+)::)?(?:(?<env>[^.\/}]+)\.)?(?:(?<path>[^}]+)\/)?(?<key>[^}]+)}/g;

export const normalizeKey = (env: string, path: string, key: string) =>
`${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`;
export const normalizeKey = (env: string, path: string, key: string, app?: string) =>
`${app ? `${app}:` : ''}${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`;

export function parseSecretReference(reference: string): SecretReference {
const match = new RegExp(REFERENCE_REGEX.source).exec(reference);
if (!match?.groups) {
throw new Error(`Invalid secret reference format: ${reference}`);
}

let { env, path, key } = match.groups;
env = env?.trim() || "";
key = key.trim();
path = path ? `/${path.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/";
const { app: appMatch, env: envMatch, path: pathMatch, key: keyMatch } = match.groups;
const app = appMatch?.trim() || null;
const env = envMatch?.trim() || null;
const key = keyMatch.trim();
const path = pathMatch ? `/${pathMatch.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/";

return { env, path, key };
return { app, env, path, key };
}

export async function resolveSecretReferences(
value: string,
currentEnv: string,
currentPath: string,
fetcher: SecretFetcher,
cache: Map<string, string> = new Map(),
resolutionStack: Set<string> = new Set()
currentApp?: string | null,
cache = new Map<string, string>(),
resolutionStack = new Set<string>()
): Promise<string> {
// Skip processing if there are no references to resolve
if (!value.includes("${")) {
return value;
}

const references = Array.from(value.matchAll(REFERENCE_REGEX));
let resolvedValue = value;

for (const ref of references) {
try {
const {
app: refApp,
env: refEnv,
path: refPath,
key: refKey,
} = parseSecretReference(ref[0]);

const targetApp = refApp || currentApp;
const targetEnv = refEnv || currentEnv;
const targetPath = refPath || currentPath;
const cacheKey = normalizeKey(targetEnv, targetPath, refKey);
const targetPath = refPath || currentPath || "/";

// Create cache key from normalized values
const cacheKey = normalizeKey(
targetEnv || "",
targetPath,
refKey,
targetApp || undefined
);

// Check for circular references
if (resolutionStack.has(cacheKey)) {
throw new Error(`Circular reference detected: ${cacheKey}`);
console.warn(`Circular reference detected: ${ref[0]} → ${cacheKey}`);
continue;
}

// Resolve the reference if not in cache
if (!cache.has(cacheKey)) {
resolutionStack.add(cacheKey);
try {
const secret = await fetcher(targetEnv, targetPath, refKey);
// Fetch the referenced secret
const secret = await fetcher(targetEnv || "", targetPath, refKey, targetApp);

// Recursively resolve any references in the secret value
const resolvedSecretValue = await resolveSecretReferences(
secret.value,
targetEnv,
targetPath,
fetcher,
targetApp,
cache,
resolutionStack
);

cache.set(cacheKey, resolvedSecretValue);
} catch (error: any) {
console.warn(`Failed to resolve reference ${ref[0]}: ${error.message || error}`);
resolutionStack.delete(cacheKey);
continue;
} finally {
resolutionStack.delete(cacheKey);
}
}

// Replace the reference with its resolved value
resolvedValue = resolvedValue.replace(ref[0], cache.get(cacheKey)!);
} catch (error) {
console.error(`Error resolving reference ${ref[0]}:`, error);
throw error;
} catch (error: any) {
console.warn(`Error resolving reference ${ref[0]}: ${error.message || error}`);
}
}

Expand Down
Loading