Redact AWS remote import provider errors

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent 3caf4e604a
commit ea62aa30e2
8 files changed
+437 -23

No files matched your search

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { SecretProviderClientError } from "../secrets/types.js";
describe("awsSecretsManagerProvider", () => {
const previousEnv = {
@@ -398,6 +399,57 @@ describe("awsSecretsManagerProvider", () => {
expect(JSON.stringify(listed)).not.toContain("team");
});
it("redacts AWS provider exception text when remote listing fails", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets() {
throw new Error(rawProviderMessage);
},
},
});
let thrown: unknown;
try {
await provider.listRemoteSecrets?.({});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(SecretProviderClientError);
expect(thrown).toMatchObject({
code: "access_denied",
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
});
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
});
it("resolves AWS secret values by provider version reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
+26 -1
View File
@@ -3,7 +3,7 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { secretRoutes } from "../routes/secrets.js";
import { errorHandler } from "../middleware/error-handler.js";
import { unprocessable } from "../errors.js";
import { HttpError, unprocessable } from "../errors.js";
const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
@@ -301,6 +301,31 @@ describe("secret routes", () => {
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("returns sanitized remote import preview provider errors", async () => {
mockSecretService.previewRemoteImport.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{ code: "access_denied" },
),
);
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("imports remote references and logs aggregate row counts", async () => {
mockSecretService.importRemoteSecrets.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
@@ -16,6 +16,7 @@ import {
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { SecretProviderClientError } from "../secrets/types.js";
import { secretService } from "../services/secrets.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -448,6 +449,44 @@ describeEmbeddedPostgres("secretService", () => {
expect(preview.candidates[2]?.providerMetadata).toBeNull();
});
it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "listSecrets",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
let thrown: unknown;
try {
await svc.previewRemoteImport(companyId, { providerConfigId: awsVault.id });
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
expect(JSON.stringify(thrown)).not.toContain("123456789012");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
});
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -519,6 +558,53 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(versions[0])).not.toContain("sk-");
});
it("sanitizes AWS remote import row provider errors", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "linkExternalSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
const result = await svc.importRemoteSecrets(companyId, {
providerConfigId: awsVault.id,
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
},
],
});
expect(result).toMatchObject({
importedCount: 0,
skippedCount: 0,
errorCount: 1,
results: [
expect.objectContaining({
status: "error",
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
}),
],
});
expect(JSON.stringify(result)).not.toContain(rawProviderMessage);
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("arn:aws");
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("123456789012");
});
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -4,10 +4,11 @@ import { Hash } from "@smithy/hash-node";
import { HttpRequest } from "@smithy/protocol-http";
import { SignatureV4 } from "@smithy/signature-v4";
import type { DeploymentMode } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { unprocessable } from "../errors.js";
import type {
PreparedSecretVersion,
RemoteSecretListResult,
SecretProviderClientErrorCode,
SecretProviderHealthCheck,
SecretProviderModule,
SecretProviderValidationResult,
@@ -15,6 +16,7 @@ import type {
SecretProviderWriteContext,
StoredSecretVersionMaterial,
} from "./types.js";
import { SecretProviderClientError } from "./types.js";
const AWS_SECRETS_MANAGER_SCHEME = "aws_secrets_manager_v1";
const DEFAULT_PREFIX = "paperclip";
@@ -464,15 +466,49 @@ function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSec
throw unprocessable("Invalid AWS Secrets Manager material");
}
function classifyAwsProviderError(message: string): SecretProviderClientErrorCode {
if (/ResourceExistsException|AlreadyExists/i.test(message)) return "conflict";
if (/ResourceNotFoundException|NotFound/i.test(message)) return "not_found";
if (/AccessDeniedException|AccessDenied|UnrecognizedClientException|InvalidClientTokenId|not authorized/i.test(message)) {
return "access_denied";
}
if (/Throttl|TooManyRequests|RequestLimitExceeded|Rate exceeded/i.test(message)) return "throttled";
if (/ValidationException|InvalidParameter|InvalidRequest/i.test(message)) return "invalid_request";
if (/fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|network|timeout/i.test(message)) return "provider_unavailable";
return "provider_error";
}
function awsProviderSafeMessage(code: SecretProviderClientErrorCode): string {
switch (code) {
case "access_denied":
return "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.";
case "throttled":
return "AWS Secrets Manager throttled the request. Wait and try again.";
case "not_found":
return "AWS Secrets Manager could not find the requested secret.";
case "conflict":
return "AWS Secrets Manager reported that the requested secret already exists.";
case "invalid_request":
return "AWS Secrets Manager rejected the request.";
case "provider_unavailable":
return "AWS Secrets Manager is unavailable right now.";
case "provider_error":
default:
return "AWS Secrets Manager request failed.";
}
}
function normalizeAwsError(operation: string, error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
if (/ResourceExistsException/i.test(message)) {
throw conflict(`AWS Secrets Manager ${operation} conflict: ${message}`);
}
if (/ResourceNotFoundException/i.test(message)) {
throw notFound(`AWS Secrets Manager ${operation} failed: ${message}`);
}
throw unprocessable(`AWS Secrets Manager ${operation} failed: ${message}`);
const rawMessage = error instanceof Error ? error.message : String(error);
const code = classifyAwsProviderError(rawMessage);
throw new SecretProviderClientError({
code,
provider: "aws_secrets_manager",
operation,
message: awsProviderSafeMessage(code),
rawMessage,
cause: error,
});
}
class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway {
+58
View File
@@ -41,6 +41,64 @@ export interface RemoteSecretListResult {
nextToken?: string | null;
}
export type SecretProviderClientErrorCode =
| "access_denied"
| "throttled"
| "not_found"
| "conflict"
| "invalid_request"
| "provider_unavailable"
| "provider_error";
export interface SecretProviderClientErrorOptions {
code: SecretProviderClientErrorCode;
provider: SecretProvider;
operation: string;
message: string;
status?: number;
rawMessage?: string | null;
cause?: unknown;
}
const SECRET_PROVIDER_CLIENT_ERROR_STATUS: Record<SecretProviderClientErrorCode, number> = {
access_denied: 403,
throttled: 429,
not_found: 404,
conflict: 409,
invalid_request: 422,
provider_unavailable: 503,
provider_error: 502,
};
export class SecretProviderClientError extends Error {
readonly code: SecretProviderClientErrorCode;
readonly provider: SecretProvider;
readonly operation: string;
readonly status: number;
readonly rawMessage: string | null;
constructor(options: SecretProviderClientErrorOptions) {
super(options.message);
this.name = "SecretProviderClientError";
this.code = options.code;
this.provider = options.provider;
this.operation = options.operation;
this.status = options.status ?? SECRET_PROVIDER_CLIENT_ERROR_STATUS[options.code];
this.rawMessage = options.rawMessage ?? null;
if (options.cause !== undefined) {
Object.defineProperty(this, "cause", {
value: options.cause,
enumerable: false,
configurable: true,
});
}
}
}
export function isSecretProviderClientError(error: unknown): error is SecretProviderClientError {
return error instanceof SecretProviderClientError;
}
export interface SecretProviderRuntimeContext {
companyId: string;
secretId: string;
+108 -10
View File
@@ -36,7 +36,8 @@ import {
secretProviderConfigPayloadSchema,
updateSecretProviderConfigSchema,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import {
checkSecretProviders,
getSecretProvider,
@@ -44,9 +45,11 @@ import {
} from "../secrets/provider-registry.js";
import type {
PreparedSecretVersion,
RemoteSecretListResult,
SecretProviderHealthCheck,
SecretProviderVaultRuntimeConfig,
} from "../secrets/types.js";
import { isSecretProviderClientError } from "../secrets/types.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const SENSITIVE_ENV_KEY_RE =
@@ -57,6 +60,76 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
"vault",
]);
function remoteProviderHttpError(error: unknown, context: {
companyId: string;
provider: SecretProvider;
providerConfigId: string;
operation: string;
}): HttpError {
if (isSecretProviderClientError(error)) {
logger.warn(
{
err: error,
companyId: context.companyId,
provider: context.provider,
providerConfigId: context.providerConfigId,
operation: context.operation,
providerErrorCode: error.code,
},
"remote secret provider request failed",
);
return new HttpError(error.status, error.message, { code: error.code });
}
if (error instanceof HttpError) return error;
logger.warn(
{
err: error,
companyId: context.companyId,
provider: context.provider,
providerConfigId: context.providerConfigId,
operation: context.operation,
providerErrorCode: "provider_error",
},
"remote secret provider request failed",
);
return new HttpError(502, "Remote secret provider request failed.", { code: "provider_error" });
}
function remoteImportRowFailureReason(error: unknown, fallback: string, context: {
companyId: string;
provider: SecretProvider;
providerConfigId: string;
operation: string;
}): string {
if (isSecretProviderClientError(error)) {
logger.warn(
{
err: error,
companyId: context.companyId,
provider: context.provider,
providerConfigId: context.providerConfigId,
operation: context.operation,
providerErrorCode: error.code,
},
"remote secret import row provider failure",
);
return error.message;
}
if (error instanceof HttpError && error.status < 500) return error.message;
logger.warn(
{
err: error,
companyId: context.companyId,
provider: context.provider,
providerConfigId: context.providerConfigId,
operation: context.operation,
providerErrorCode: "provider_error",
},
"remote secret import row failed",
);
return fallback;
}
type CanonicalEnvBinding =
| { type: "plain"; value: string }
| { type: "secret_ref"; secretId: string; version: number | "latest" };
@@ -1038,12 +1111,22 @@ export function secretService(db: Db) {
if (!provider.listRemoteSecrets) {
throw unprocessable(`${providerId} provider does not support remote import listing`);
}
const listed = await provider.listRemoteSecrets({
providerConfig: runtimeConfig,
query: input.query,
nextToken: input.nextToken,
pageSize: input.pageSize,
});
let listed: RemoteSecretListResult;
try {
listed = await provider.listRemoteSecrets({
providerConfig: runtimeConfig,
query: input.query,
nextToken: input.nextToken,
pageSize: input.pageSize,
});
} catch (error) {
throw remoteProviderHttpError(error, {
companyId,
provider: providerId,
providerConfigId: providerConfig.id,
operation: "remote_import.preview",
});
}
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
const candidates: RemoteSecretImportCandidate[] = [];
for (const remote of listed.secrets) {
@@ -1069,7 +1152,12 @@ export function secretService(db: Db) {
} catch (error) {
conflicts.push({
type: "provider_guardrail",
message: error instanceof Error ? error.message : "Provider rejected this external reference",
message: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
companyId,
provider: providerId,
providerConfigId: providerConfig.id,
operation: "remote_import.preview.link_external_reference",
}),
});
}
conflicts.push(...remoteImportConflictsFor({
@@ -1179,7 +1267,12 @@ export function secretService(db: Db) {
name,
key,
status: "error",
reason: error instanceof Error ? error.message : "Provider rejected this external reference",
reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
companyId,
provider: providerId,
providerConfigId: providerConfig.id,
operation: "remote_import.prepare_external_reference",
}),
secretId: null,
conflicts: [],
});
@@ -1274,7 +1367,12 @@ export function secretService(db: Db) {
name,
key,
status: "error",
reason: error instanceof Error ? error.message : "Import failed",
reason: remoteImportRowFailureReason(error, "Import failed", {
companyId,
provider: providerId,
providerConfigId: providerConfig.id,
operation: "remote_import.commit",
}),
secretId: null,
conflicts: [],
});
@@ -11,6 +11,7 @@ import type {
RemoteSecretImportResult,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client";
const mockSecretsApi = vi.hoisted(() => ({
remoteImportPreview: vi.fn(),
@@ -412,7 +413,7 @@ describe("ImportFromVaultDialog", () => {
name: "gamma",
key: "gamma",
status: "error",
reason: "AccessDeniedException for arn:aws:…/c",
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
secretId: null,
conflicts: [],
},
@@ -473,7 +474,9 @@ describe("ImportFromVaultDialog", () => {
expect(document.body.textContent).toContain("1 created");
expect(document.body.textContent).toContain("1 skipped");
expect(document.body.textContent).toContain("1 failed");
expect(document.body.textContent).toContain("AccessDeniedException");
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
expect(document.body.textContent).not.toContain("AccessDeniedException");
expect(document.body.textContent).not.toContain("123456789012");
await act(async () => {
root.unmount();
@@ -542,6 +545,48 @@ describe("ImportFromVaultDialog", () => {
});
});
it("renders sanitized preview provider errors without raw AWS exception text", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(
new ApiError(
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
403,
{ error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } },
),
);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const banner = document.querySelector('[data-testid="preview-error-banner"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("AWS denied list access");
expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets");
expect(banner?.textContent).not.toContain(rawProviderMessage);
expect(banner?.textContent).not.toContain("arn:aws");
expect(banner?.textContent).not.toContain("123456789012");
await act(async () => {
root.unmount();
});
});
it("debounces search and uses the new query for the next preview", async () => {
mockSecretsApi.remoteImportPreview
.mockResolvedValueOnce(makePreview([makeCandidate()]))
+15 -1
View File
@@ -201,8 +201,22 @@ function readableErrorMessage(error: unknown): string {
return "Unexpected error";
}
function apiErrorCode(error: ApiError): string | null {
const body = error.body;
if (!body || typeof body !== "object") return null;
const record = body as Record<string, unknown>;
if (typeof record.code === "string") return record.code;
const details = record.details;
if (details && typeof details === "object") {
const code = (details as Record<string, unknown>).code;
if (typeof code === "string") return code;
}
return null;
}
function isPermissionError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false;
if (apiErrorCode(error) === "access_denied") return true;
if (error.status === 401 || error.status === 403) return true;
const message = error.message.toLowerCase();
return (
@@ -214,6 +228,7 @@ function isPermissionError(error: unknown): boolean {
function isThrottlingError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false;
if (apiErrorCode(error) === "throttled") return true;
const message = error.message.toLowerCase();
return message.includes("throttl") || message.includes("toomanyrequests");
}
@@ -1458,4 +1473,3 @@ function FooterStatus({
}
return null;
}