stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)
This commit is contained in:
28 files changed
+667
-100
No files matched your search
+61
-1
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared';
|
||||
import { SECRET_CONFIG_KEYS } from './secretproviders/shared';
|
||||
import {
|
||||
db,
|
||||
isPostgres,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
import { parseInjectedSecretKeys, serializeInjectedSecretKeys } from './stack-secret-keys';
|
||||
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -375,7 +377,25 @@ export async function updateSecretProvider(
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.type !== undefined) updateData.type = data.type;
|
||||
if (data.config !== undefined) {
|
||||
const encrypted = encrypt(JSON.stringify(data.config));
|
||||
// The edit form pre-fills non-secret fields but leaves the token blank to mean
|
||||
// "keep the stored secret". Merge the incoming config OVER the existing one so a
|
||||
// blank/absent secret keeps its stored value instead of being wiped. Only applies
|
||||
// when the provider type is unchanged (a type change is a full re-config).
|
||||
let merged: Record<string, unknown> = { ...(data.config as Record<string, unknown>) };
|
||||
const typeUnchanged = data.type === undefined;
|
||||
if (typeUnchanged) {
|
||||
const existing = await getSecretProviderById(id);
|
||||
if (existing) {
|
||||
for (const key of SECRET_CONFIG_KEYS) {
|
||||
const incoming = (data.config as Record<string, unknown>)[key];
|
||||
if (incoming === undefined || incoming === '') {
|
||||
const stored = (existing.config as Record<string, unknown>)[key];
|
||||
if (stored !== undefined) merged[key] = stored;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const encrypted = encrypt(JSON.stringify(merged));
|
||||
if (encrypted) updateData.config = encrypted;
|
||||
}
|
||||
await db.update(secretProviders).set(updateData).where(eq(secretProviders.id, id));
|
||||
@@ -3075,6 +3095,40 @@ export async function updateStackSource(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the names (no values) of secret keys injected from the bound provider on
|
||||
* the last deploy, so container inspect can mask them without a live provider call.
|
||||
* Stored as a JSON array on stack_sources; null clears it. No-op if the stack has
|
||||
* no source row.
|
||||
*/
|
||||
export async function setStackInjectedSecretKeys(
|
||||
stackName: string,
|
||||
environmentId: number | null | undefined,
|
||||
keys: string[]
|
||||
): Promise<void> {
|
||||
const existing = await getStackSource(stackName, environmentId ?? null);
|
||||
if (!existing) return;
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
injectedSecretKeys: serializeInjectedSecretKeys(keys),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read back the provider-injected secret key names for a stack (empty if none).
|
||||
* Tolerates a malformed/legacy value by returning an empty set.
|
||||
*/
|
||||
export async function getStackInjectedSecretKeys(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const source = await getStackSource(stackName, environmentId ?? null);
|
||||
const raw = (source as { injectedSecretKeys?: string | null } | null)?.injectedSecretKeys;
|
||||
return parseInjectedSecretKeys(raw);
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
@@ -5241,6 +5295,12 @@ export async function getSecretKeysToMask(
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
|
||||
// Provider-injected secrets (Vault/Doppler bulk keys, promoted op:// refs) live
|
||||
// only in the provider, never in stack_env_vars, so add the names persisted on
|
||||
// the last deploy. Without this they leak plaintext in container inspect.
|
||||
const injected = await getStackInjectedSecretKeys(stackName, environmentId);
|
||||
for (const key of injected) secretKeyNames.add(key);
|
||||
|
||||
if (secretKeyNames.size === 0) return secretKeyNames;
|
||||
|
||||
// If we have compose content, parse interpolation references to find
|
||||
|
||||
@@ -354,6 +354,9 @@ export const stackSources = sqliteTable('stack_sources', {
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }),
|
||||
// Names (no values) of secret keys injected from the bound provider on the last
|
||||
// deploy, so container inspect can mask them without a live provider call.
|
||||
injectedSecretKeys: text('injected_secret_keys'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -357,6 +357,9 @@ export const stackSources = pgTable('stack_sources', {
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }),
|
||||
// Names (no values) of secret keys injected from the bound provider on the last
|
||||
// deploy, so container inspect can mask them without a live provider call.
|
||||
injectedSecretKeys: text('injected_secret_keys'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
@@ -365,7 +365,8 @@ export async function migrateCredentials(): Promise<void> {
|
||||
ldapConfig,
|
||||
notificationSettings,
|
||||
stackEnvironmentVariables,
|
||||
backupDestinations
|
||||
backupDestinations,
|
||||
secretProviders
|
||||
} = await import('./db/drizzle.js');
|
||||
|
||||
let migrated = 0;
|
||||
@@ -461,6 +462,13 @@ export async function migrateCredentials(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const providers = await db.select().from(secretProviders);
|
||||
for (const p of providers) {
|
||||
if (p.config && isEncrypted(p.config)) {
|
||||
allEncrypted.push({ table: 'secretProviders', id: p.id, field: 'config', value: p.config });
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt all values with old key (still cached at this point)
|
||||
const decryptedValues: Map<string, string> = new Map();
|
||||
for (const item of allEncrypted) {
|
||||
@@ -510,6 +518,8 @@ export async function migrateCredentials(): Promise<void> {
|
||||
} else if (item.table === 'backupDestinations') {
|
||||
// Both password and envVars are plain encrypted columns.
|
||||
await tx.update(backupDestinations).set({ [item.field]: reEncrypted }).where(eq(backupDestinations.id, item.id));
|
||||
} else if (item.table === 'secretProviders') {
|
||||
await tx.update(secretProviders).set({ config: reEncrypted }).where(eq(secretProviders.id, item.id));
|
||||
}
|
||||
|
||||
migrated++;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { ConnectConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** A vault as returned by GET /v1/vaults. */
|
||||
@@ -72,6 +73,7 @@ class ReferenceNotFoundError extends Error {
|
||||
|
||||
/** Normalises a host URL into the Connect v1 API base (no trailing slash). */
|
||||
function baseUrl(host: string): string {
|
||||
assertSafeProviderHost(host, '1Password Connect');
|
||||
return `${host.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
@@ -132,8 +134,11 @@ async function connectGet<T>(
|
||||
return undefined;
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await body.text();
|
||||
throw new Error(`1Password Connect request failed (${statusCode}): ${detail || 'no body'}`);
|
||||
// Log the raw body server-side only; the thrown message (which reaches the
|
||||
// deploy job / client) carries just the status, never arbitrary upstream bytes.
|
||||
const detail = await body.text().catch(() => '');
|
||||
if (detail) console.warn(`[1Password Connect] request ${statusCode}: ${detail}`);
|
||||
throw new Error(`1Password Connect request failed (${statusCode})`);
|
||||
}
|
||||
return (await body.json()) as T;
|
||||
}
|
||||
@@ -251,9 +256,13 @@ export const connectProvider: SecretProvider<ConnectConfig> = {
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return { ok: false, error: 'Invalid 1Password Connect token' };
|
||||
}
|
||||
// Log the raw body server-side; show the client only a message parsed from
|
||||
// Connect's own {message}/{messages} error shape (never arbitrary bytes).
|
||||
if (text) console.warn(`[1Password Connect] testConnection ${statusCode}: ${text}`);
|
||||
const safe = parseProviderError(text);
|
||||
return {
|
||||
ok: false,
|
||||
error: `1Password Connect request failed (${statusCode}): ${text || 'no body'}`
|
||||
error: `1Password Connect request failed (${statusCode})${safe ? `: ${safe}` : ''}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Doppler provider.
|
||||
*
|
||||
* Talks to Doppler's REST API to bulk-pull every secret in a config. Doppler has
|
||||
* no inline compose-reference syntax, so this provider is bulk-only:
|
||||
* `resolveSecretReferences` is unsupported and `isReference` is always false. A
|
||||
* SERVICE token (dp.st.) already identifies the project + config; a PERSONAL token
|
||||
* (dp.pt.) is account-wide and needs project/config supplied in the config. Either
|
||||
* way the DOCKHAND_SECRET_SELECTOR is ignored (the config, not the selector, is
|
||||
* the scope).
|
||||
*
|
||||
* Auth is a Doppler service token sent as `Authorization: Bearer <token>`. The
|
||||
* download endpoint returns a flat `{ KEY: value }` JSON map when asked for
|
||||
* `format=json`. `host` defaults to Doppler's SaaS API and is overridable only
|
||||
* for testing against a stub (Doppler is not self-hostable).
|
||||
*
|
||||
* Decrypted tokens stay inside this module for the duration of a call. Nothing
|
||||
* here writes to disk or to the database.
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { DopplerConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost, parseProviderError } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
const DEFAULT_HOST = 'https://api.doppler.com';
|
||||
|
||||
/**
|
||||
* Keys Doppler injects into EVERY config's download - its own metadata about the
|
||||
* project/config the token is scoped to, not the user's secrets. We drop them so
|
||||
* they never leak into the stack's environment. (Confirmed against the live API:
|
||||
* every config returns exactly these three alongside the real secrets.)
|
||||
*/
|
||||
export const DOPPLER_META_KEYS = new Set(['DOPPLER_PROJECT', 'DOPPLER_CONFIG', 'DOPPLER_ENVIRONMENT']);
|
||||
|
||||
/** Strips a trailing slash so `${base}${path}` never doubles up. */
|
||||
function baseUrl(host: string | undefined): string {
|
||||
const resolved = (host?.trim() || DEFAULT_HOST).replace(/\/$/, '');
|
||||
assertSafeProviderHost(resolved, 'Doppler');
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The download URL. `include_dynamic_secrets=false` keeps the response a plain
|
||||
* flat map (dynamic secrets would nest). A service token already implies the
|
||||
* project/config; a personal token needs them as query params (else the API
|
||||
* returns 400). We append whichever were provided and let Doppler decide.
|
||||
*/
|
||||
function downloadUrl(config: DopplerConfig): string {
|
||||
let url = `${baseUrl(config.host)}/v3/configs/config/secrets/download?format=json&include_dynamic_secrets=false`;
|
||||
if (config.project?.trim()) url += `&project=${encodeURIComponent(config.project.trim())}`;
|
||||
if (config.config?.trim()) url += `&config=${encodeURIComponent(config.config.trim())}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export const dopplerProvider: SecretProvider<DopplerConfig> = {
|
||||
type: 'doppler',
|
||||
label: 'Doppler',
|
||||
supportsReferences: false,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(_value: unknown): _value is string {
|
||||
return false;
|
||||
},
|
||||
|
||||
async testConnection(config: DopplerConfig): Promise<TestConnectionResult> {
|
||||
const token = config.token?.trim();
|
||||
if (!token) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
try {
|
||||
const { statusCode, body } = await request(downloadUrl(config), {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
// Drain the body; on failure log it server-side and show the client only a
|
||||
// message parsed from Doppler's own {messages:[...]} shape.
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (rawBody) console.warn(`[Doppler] testConnection ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return { ok: false, error: `Doppler returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(): Promise<Map<string, string>> {
|
||||
throw new UnsupportedOperationError(
|
||||
'Doppler does not support inline references; a service token bulk-pulls its whole config.'
|
||||
);
|
||||
},
|
||||
|
||||
async resolveBulk(config: DopplerConfig, _selector: string): Promise<Record<string, string>> {
|
||||
const token = config.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error('[Doppler] Token is required for a bulk pull');
|
||||
}
|
||||
|
||||
const { statusCode, body } = await request(downloadUrl(config), {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await body.text().catch(() => '');
|
||||
throw new Error(`[Doppler] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
// format=json returns a flat { KEY: value } object; coerce non-string values
|
||||
// and drop Doppler's own metadata keys (DOPPLER_META_KEYS).
|
||||
const payload = (await body.json()) as Record<string, unknown>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(payload ?? {})) {
|
||||
if (DOPPLER_META_KEYS.has(key)) continue;
|
||||
if (value !== null && value !== undefined) {
|
||||
result[key] = typeof value === 'string' ? value : String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { serviceAccountProvider } from './service-account';
|
||||
import { connectProvider } from './connect';
|
||||
import { infisicalProvider } from './infisical';
|
||||
import { vaultProvider } from './vault';
|
||||
import { dopplerProvider } from './doppler';
|
||||
|
||||
// Registered providers. Adding a new backend means dropping a file in this
|
||||
// directory and registering it here; each implements the SecretProvider
|
||||
@@ -25,7 +26,8 @@ const providers: Record<string, SecretProvider> = {
|
||||
[serviceAccountProvider.type]: serviceAccountProvider as SecretProvider,
|
||||
[connectProvider.type]: connectProvider as SecretProvider,
|
||||
[infisicalProvider.type]: infisicalProvider as SecretProvider,
|
||||
[vaultProvider.type]: vaultProvider as SecretProvider
|
||||
[vaultProvider.type]: vaultProvider as SecretProvider,
|
||||
[dopplerProvider.type]: dopplerProvider as SecretProvider
|
||||
};
|
||||
|
||||
/** Returns the provider for a stored type, or undefined if unknown. */
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { InfisicalConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost, parseProviderError } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** Shape of a single secret in the /api/v3/secrets/raw response. */
|
||||
@@ -33,6 +34,7 @@ interface RawSecretsResponse {
|
||||
|
||||
/** Strips a trailing slash so `${base}${path}` never doubles up. */
|
||||
function baseUrl(host: string): string {
|
||||
assertSafeProviderHost(host, 'Infisical');
|
||||
return host.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
@@ -91,12 +93,15 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
// Always drain the body so undici can reuse the connection.
|
||||
await body.text().catch(() => '');
|
||||
// Drain the body; on failure log it server-side and show the client only a
|
||||
// message parsed from Infisical's own {message}/{messages} shape.
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}` };
|
||||
if (rawBody) console.warn(`[Infisical] testConnection ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
@@ -138,11 +143,10 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
// Drain the body so the connection can be reused, then surface the status.
|
||||
// Drain the body, log it server-side, but never reflect it to the client.
|
||||
const detail = await body.text().catch(() => '');
|
||||
throw new Error(
|
||||
`[Infisical] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
if (detail) console.warn(`[Infisical] bulk pull ${statusCode}: ${detail}`);
|
||||
throw new Error(`[Infisical] Bulk pull failed with HTTP ${statusCode}`);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as RawSecretsResponse;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* persisted here — providers hold it only for the duration of a call.
|
||||
*/
|
||||
|
||||
import { isSafeNotificationUrl } from '../url-safety';
|
||||
|
||||
/**
|
||||
* Identifies which backend a stored provider row talks to. Matches the
|
||||
* `secret_providers.type` column and the API. Kept as a widenable string union
|
||||
@@ -28,9 +30,78 @@ export type SecretProviderType =
|
||||
| 'op-connect'
|
||||
| 'infisical'
|
||||
| 'vault'
|
||||
| 'doppler'
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* SSRF guard for a provider's user-supplied host/address. A provider makes
|
||||
* server-side HTTP requests to whatever host the user configured, so an
|
||||
* unchecked value lets an authenticated user pivot at Dockhand's network
|
||||
* (cloud-metadata 169.254.169.254, loopback, internal services) and read the
|
||||
* response back through testConnection / the bulk pull. Same policy as
|
||||
* notifications: block loopback + cloud metadata, allow ordinary LAN ranges so a
|
||||
* self-hosted Vault/Infisical/Connect on the local network still works. Throws on
|
||||
* an unsafe host; call it before the first request in every REST provider.
|
||||
*/
|
||||
export function assertSafeProviderHost(rawUrl: string, label: string): void {
|
||||
const safe = isSafeNotificationUrl(rawUrl);
|
||||
if (!safe.ok) {
|
||||
throw new Error(`${label}: host not allowed (${safe.reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a user-supplied selector/path before it is concatenated into a
|
||||
* provider's API URL (Vault KV path, Infisical secretPath). The selector comes
|
||||
* from a stack env var an operator controls, so an unencoded value could inject
|
||||
* `..` traversal or `?`/`#`/scheme characters and forge a request to a different
|
||||
* endpoint on the same server. Rejects `..` segments and percent-encodes each
|
||||
* path segment; a leading slash is stripped by the caller as before.
|
||||
*/
|
||||
export function sanitizeSelectorPath(selector: string, label: string): string {
|
||||
const trimmed = selector.trim().replace(/^\/+/, '');
|
||||
const segments = trimmed.split('/');
|
||||
for (const seg of segments) {
|
||||
if (seg === '..' || seg === '.') {
|
||||
throw new Error(`${label}: selector must not contain path traversal ("${selector}")`);
|
||||
}
|
||||
}
|
||||
return segments.map((s) => encodeURIComponent(s)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a short, human error message from a provider's error RESPONSE BODY, but
|
||||
* only when the body parses as that provider's own documented error JSON shape.
|
||||
* Used by testConnection (interactive setup) to show an actionable message like
|
||||
* "permission denied" or "Invalid Auth token" WITHOUT reflecting arbitrary upstream
|
||||
* bytes: a non-provider host (SSRF probe of a LAN service) returns HTML/other text
|
||||
* that does not match the shape, so nothing leaks. Returns null when the body is
|
||||
* absent, unparseable, or not the expected shape - the caller then shows status only.
|
||||
*
|
||||
* Shapes: Vault `{errors:[...]}`, Doppler/Infisical `{messages:[...]}` / `{message}`.
|
||||
*/
|
||||
export function parseProviderError(rawBody: string | undefined | null): string | null {
|
||||
if (!rawBody) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null; // not JSON -> not a known provider error shape
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const pick = (v: unknown): string | null => {
|
||||
if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 200);
|
||||
if (Array.isArray(v)) {
|
||||
const strs = v.filter((x) => typeof x === 'string' && x.trim()) as string[];
|
||||
if (strs.length) return strs.join('; ').slice(0, 200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null;
|
||||
}
|
||||
|
||||
/** 1Password service account: a single bearer token. */
|
||||
export interface ServiceAccountConfig {
|
||||
token: string;
|
||||
@@ -66,12 +137,52 @@ export interface VaultConfig {
|
||||
mount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Doppler: bulk-only. A SERVICE token (dp.st.) is scoped to one config, so the
|
||||
* token alone identifies it and `project`/`config` are not needed. A PERSONAL
|
||||
* token (dp.pt.) is account-wide, so it MUST be told which project + config to
|
||||
* download (the API returns 400 "You must specify a project" otherwise). `host`
|
||||
* defaults to Doppler's SaaS API; it is overridable only for testing against a
|
||||
* stub (Doppler has no self-hosted server).
|
||||
*/
|
||||
export interface DopplerConfig {
|
||||
token: string;
|
||||
host?: string;
|
||||
/** Required for a personal token (dp.pt.); ignored for a service token. */
|
||||
project?: string;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
/** Persisted (encrypted) config, discriminated by the provider `type`. */
|
||||
export type SecretProviderConfig =
|
||||
| ServiceAccountConfig
|
||||
| ConnectConfig
|
||||
| InfisicalConfig
|
||||
| VaultConfig;
|
||||
| VaultConfig
|
||||
| DopplerConfig;
|
||||
|
||||
/**
|
||||
* Config keys that hold a SECRET across every provider type. Only these are stripped
|
||||
* when a provider config is sent to the client (the edit form); everything else
|
||||
* (host, address, projectId, environment, path, namespace, mount, project, config) is
|
||||
* a non-secret coordinate the user needs to see and edit. Keep in sync with the
|
||||
* `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS.
|
||||
*/
|
||||
export const SECRET_CONFIG_KEYS = new Set(['token']);
|
||||
|
||||
/**
|
||||
* Returns a copy of a provider config with secret values removed, so the edit form
|
||||
* can pre-fill the non-secret coordinates without the token ever leaving the server.
|
||||
* A stripped secret is simply absent (the form treats a blank secret field as
|
||||
* "keep the stored value").
|
||||
*/
|
||||
export function redactProviderConfig(config: SecretProviderConfig): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
ok: boolean;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
import { UnsupportedOperationError, assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from './shared';
|
||||
import type { SecretProvider, TestConnectionResult, VaultConfig } from './shared';
|
||||
|
||||
/** Undici response body handle, derived so we don't import undici's types. */
|
||||
@@ -34,6 +34,7 @@ interface KvV2ReadResponse {
|
||||
|
||||
/** `${address}/v1`, with any trailing slash on the address removed. */
|
||||
function apiBase(config: VaultConfig): string {
|
||||
assertSafeProviderHost(config.address, 'HashiCorp Vault');
|
||||
return `${config.address.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
@@ -97,13 +98,18 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
headers: authHeaders(config)
|
||||
});
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
await body.dump();
|
||||
await body.text().catch(() => '');
|
||||
return { ok: true };
|
||||
}
|
||||
const detail = await readErrorDetail(body);
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
// Log the full upstream body server-side; show the client only a message
|
||||
// parsed from Vault's own {errors:[...]} shape (a non-Vault host probed via
|
||||
// SSRF returns other text that won't parse, so nothing leaks).
|
||||
if (rawBody) console.warn(`[HashiCorp Vault] token lookup ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Vault returned ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
error: `Vault returned ${statusCode}${safe ? `: ${safe}` : ''}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
@@ -122,8 +128,8 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
},
|
||||
|
||||
async resolveBulk(config: VaultConfig, selector: string): Promise<Record<string, string>> {
|
||||
const mount = config.mount || 'secret';
|
||||
const path = selector.replace(/^\/+/, '');
|
||||
const mount = encodeURIComponent(config.mount || 'secret');
|
||||
const path = sanitizeSelectorPath(selector, 'HashiCorp Vault');
|
||||
const { statusCode, body } = await request(`${apiBase(config)}/${mount}/data/${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(config)
|
||||
@@ -136,9 +142,8 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await readErrorDetail(body);
|
||||
throw new Error(
|
||||
`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
if (detail) console.warn(`[HashiCorp Vault] read ${mount}/${path} ${statusCode}: ${detail}`);
|
||||
throw new Error(`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})`);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as KvV2ReadResponse;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Pure helpers for the `stack_sources.injected_secret_keys` column - the names (no
|
||||
* values) of provider-injected secrets recorded on the last deploy so container
|
||||
* inspect can mask them. Kept dependency-free (no DB import) so it unit-tests without
|
||||
* spinning up the database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses the stored JSON string[] of key names into a Set. Tolerates
|
||||
* null/empty/malformed/legacy values by returning an empty set (never throws), so a
|
||||
* bad row degrades to "nothing extra masked" rather than a 500. Non-string array
|
||||
* members are dropped.
|
||||
*/
|
||||
export function parseInjectedSecretKeys(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? new Set(parsed.filter((k) => typeof k === 'string')) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises the injected key names for storage: a JSON array, or null when empty so
|
||||
* an unbound / provider-less stack clears the column instead of storing `[]`.
|
||||
*/
|
||||
export function serializeInjectedSecretKeys(keys: string[]): string | null {
|
||||
return keys.length > 0 ? JSON.stringify(keys) : null;
|
||||
}
|
||||
@@ -58,14 +58,46 @@ export function isSafeNotificationUrl(raw: string): { ok: boolean; reason?: stri
|
||||
*/
|
||||
export type IpCategory = 'loopback' | 'metadata' | 'private' | 'reserved';
|
||||
|
||||
/**
|
||||
* Extract the embedded IPv4 dotted-quad from an IPv4-mapped/compatible IPv6 host,
|
||||
* or null. `new URL()` normalizes `::ffff:1.2.3.4` to the HEX form `::ffff:102:304`,
|
||||
* so a regex on the dotted form alone is dead on the real code path — every mapped
|
||||
* address must be canonicalized from its hextets. Handles the compressed
|
||||
* `::ffff:HHHH:HHHH`, the fully-expanded `0:0:0:0:0:ffff:HHHH:HHHH`, the
|
||||
* `::ffff:0:HHHH:HHHH` (v4-translated), and the deprecated IPv4-compatible
|
||||
* `::HHHH:HHHH` / `::a.b.c.d`. The last two 16-bit hextets carry the 32-bit v4.
|
||||
*/
|
||||
function embeddedV4(h: string): string | null {
|
||||
// Trailing dotted quad after a v4-mapped/compatible prefix, in either the
|
||||
// compressed (::ffff:1.2.3.4) or fully-expanded (0:0:0:0:0:ffff:1.2.3.4) form.
|
||||
const dotted = h.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted && /^(::ffff:|::ffff:0:|::|(0:){5}ffff:|(0:){4}0:ffff:)/.test(h)) {
|
||||
return dotted[1];
|
||||
}
|
||||
// Hex-hextet v4-mapped/compatible forms. Take the last two colon-separated
|
||||
// groups as the 32-bit v4 IFF the address is one of the embedded-v4 shapes.
|
||||
const isMapped =
|
||||
/^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^::ffff:0:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^(0:){5}ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^::[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h); // deprecated v4-compatible ::a:b
|
||||
if (!isMapped) return null;
|
||||
const groups = h.split(':').filter((g) => g.length > 0);
|
||||
if (groups.length < 2) return null;
|
||||
const hi = parseInt(groups[groups.length - 2], 16);
|
||||
const lo = parseInt(groups[groups.length - 1], 16);
|
||||
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
||||
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
export function ipCategory(host: string): IpCategory | null {
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
// IPv6 loopback, then link-local / unique-local (treated as private).
|
||||
// IPv6 loopback / unspecified, then link-local / unique-local (private).
|
||||
if (h === '::1') return 'loopback';
|
||||
if (h === '::' || h === '::0') return 'reserved'; // unspecified / all-interfaces
|
||||
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return 'private';
|
||||
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4.
|
||||
const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
const v4 = mapped ? mapped[1] : h;
|
||||
// IPv4-mapped/compatible IPv6 — judge the embedded v4 (see embeddedV4).
|
||||
const v4 = embeddedV4(h) ?? h;
|
||||
const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (m) {
|
||||
const [a, b] = [Number(m[1]), Number(m[2])];
|
||||
|
||||
Reference in new issue
Block a user