stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent d7f435dd5f
commit f43736d94a
28 files changed
+667 -100

No files matched your search

+61 -1
View File
@@ -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
+3
View File
@@ -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) => ({
+3
View File
@@ -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) => ({
+11 -1
View File
@@ -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++;
+12 -3
View File
@@ -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);
+125
View File
@@ -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;
}
};
+3 -1
View File
@@ -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. */
+11 -7
View File
@@ -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;
+112 -1
View File
@@ -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;
+14 -9
View File
@@ -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;
+30
View File
@@ -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;
}
+36 -4
View File
@@ -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])];