Skip to content

Commit 23acf75

Browse files
authored
zod secret mixin (#41)
1 parent 997b4f8 commit 23acf75

9 files changed

Lines changed: 144 additions & 97 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,23 @@ Actions which have a `task` property can be scheduled as a task. A `queue` prope
155155
task = { queue: "default", frequency: 1000 * 60 * 60 }; // run the task every hour
156156
```
157157

158+
## Marking Secret Fields in Action Inputs
159+
160+
You can mark sensitive fields in your zod schemas as secret using the custom `.secret()` mixin. This is useful for fields like passwords, API keys, or tokens that should never be logged or exposed in logs.
161+
162+
**How to use:**
163+
164+
```ts
165+
inputs = z.object({
166+
email: z.string().email(),
167+
password: z.string().min(8).secret(), // This field will be redacted in logs
168+
});
169+
```
170+
171+
When an action is executed, any field marked with `.secret()` will be replaced with `[[secret]]` in logs and not exposed in any logging output, even if the action fails or validation errors occur.
172+
173+
This works for any zod field type (string, number, etc). Simply chain `.secret()` to the field definition.
174+
158175
## Intentional changes from ActionHero
159176

160177
**Multiple Applications**

backend/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
types/

backend/__tests__/actions/user.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { api, type ActionResponse } from "../../api";
33
import type { UserCreate, UserEdit } from "../../actions/user";
44
import { config } from "../../config";
55
import type { SessionCreate } from "../../actions/session";
6+
import { logger } from "../../api";
67

78
const url = config.server.web.applicationUrl;
89

@@ -68,6 +69,41 @@ describe("user:create", () => {
6869
expect(response.error?.key).toEqual("name");
6970
expect(response.error?.value).toEqual("x");
7071
});
72+
73+
test("secret fields are redacted in logs", async () => {
74+
// Mock the logger to capture log messages
75+
const originalInfo = logger.info;
76+
const logMessages: string[] = [];
77+
logger.info = (message: string) => {
78+
logMessages.push(message);
79+
};
80+
81+
try {
82+
const formData = new FormData();
83+
formData.append("name", "Test User");
84+
formData.append("email", "test@example.com");
85+
formData.append("password", "secretpassword123");
86+
87+
const res = await fetch(url + "/api/user", {
88+
method: "PUT",
89+
body: formData,
90+
});
91+
92+
// Find the log message that contains the action execution
93+
const actionLogMessage = logMessages.find(
94+
(msg) => msg.includes("[ACTION:") && msg.includes("user:create"),
95+
);
96+
97+
expect(actionLogMessage).toBeDefined();
98+
expect(actionLogMessage).toContain('"name":"Test User"');
99+
expect(actionLogMessage).toContain('"email":"test@example.com"');
100+
expect(actionLogMessage).toContain('"password":"[[secret]]"');
101+
expect(actionLogMessage).not.toContain('"password":"secretpassword123"');
102+
} finally {
103+
// Restore original logger
104+
logger.info = originalInfo;
105+
}
106+
});
71107
});
72108

73109
describe("user:edit", () => {

backend/actions/session.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export class SessionCreate implements Action {
2727
password: z
2828
.string()
2929
.min(8, "Password must be at least 8 characters")
30-
.describe("The user's password"),
30+
.describe("The user's password")
31+
.secret(),
3132
});
3233

3334
// @ts-ignore - this is a valid action and response type, but sometimes the compiler doesn't like it

backend/actions/user.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export class UserCreate implements Action {
3030
.string()
3131
.min(8, "Password must be at least 8 characters")
3232
.max(256, "Password must be less than 256 characters")
33-
.describe("The user's password"),
33+
.describe("The user's password")
34+
.secret(),
3435
});
3536

3637
async run(params: ActionParams<UserCreate>) {
@@ -68,7 +69,7 @@ export class UserEdit implements Action {
6869
inputs = z.object({
6970
name: z.string().min(1).max(256).optional(),
7071
email: z.string().email().toLowerCase().optional(),
71-
password: z.string().min(8).max(256).optional(),
72+
password: z.string().min(8).max(256).optional().secret(),
7273
});
7374

7475
async run(params: ActionParams<UserEdit>, connection: Connection) {

backend/classes/Connection.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ErrorType, TypedError } from "./TypedError";
66
import type { SessionData } from "../initializers/session";
77
import { randomUUID } from "crypto";
88
import type { PubSubMessage } from "../initializers/pubsub";
9+
import "../util/zodSecretsMixin";
910

1011
export class Connection<T extends Record<string, any> = Record<string, any>> {
1112
type: string;
@@ -232,10 +233,26 @@ const REDACTED = "[[secret]]" as const;
232233

233234
const sanitizeParams = (params: FormData, action: Action | undefined) => {
234235
const sanitizedParams: Record<string, any> = {};
236+
237+
// Get secret fields from the action's zod schema if it exists
238+
const secretFields = new Set<string>();
239+
if (action?.inputs && typeof action.inputs === "object") {
240+
const zodSchema = action.inputs as any;
241+
if (zodSchema._def?.typeName === "ZodObject" && zodSchema.shape) {
242+
for (const [fieldName, fieldSchema] of Object.entries(zodSchema.shape)) {
243+
if ((fieldSchema as any)._def?.isSecret) {
244+
secretFields.add(fieldName);
245+
}
246+
}
247+
}
248+
}
249+
235250
params.forEach((v, k) => {
236-
// For now, we don't have a way to mark fields as secret in zod schemas
237-
// This could be added later with a custom zod extension if needed
238-
sanitizedParams[k] = v;
251+
if (secretFields.has(k)) {
252+
sanitizedParams[k] = REDACTED;
253+
} else {
254+
sanitizedParams[k] = v;
255+
}
239256
});
240257

241258
return sanitizedParams;

backend/util/formatters.ts

Lines changed: 0 additions & 91 deletions
This file was deleted.

backend/util/zodSecretsMixin.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { z, type ZodTypeAny, type ZodRawShape } from "zod";
2+
3+
// Custom zod extension to mark fields as secret
4+
// This module augmentation and prototype extension allows .secret() to be used on zod fields
5+
6+
declare module "zod" {
7+
interface ZodString {
8+
secret(): ZodString;
9+
}
10+
interface ZodNumber {
11+
secret(): ZodNumber;
12+
}
13+
interface ZodBoolean {
14+
secret(): ZodBoolean;
15+
}
16+
interface ZodArray<T extends ZodTypeAny> {
17+
secret(): ZodArray<T>;
18+
}
19+
interface ZodObject<T extends ZodRawShape> {
20+
secret(): ZodObject<T>;
21+
}
22+
interface ZodOptional<T extends ZodTypeAny> {
23+
secret(): ZodOptional<T>;
24+
}
25+
interface ZodNullable<T extends ZodTypeAny> {
26+
secret(): ZodNullable<T>;
27+
}
28+
interface ZodDefault<T extends ZodTypeAny> {
29+
secret(): ZodDefault<T>;
30+
}
31+
}
32+
33+
z.ZodString.prototype.secret = function () {
34+
this._def.isSecret = true;
35+
return this;
36+
};
37+
z.ZodNumber.prototype.secret = function () {
38+
this._def.isSecret = true;
39+
return this;
40+
};
41+
z.ZodBoolean.prototype.secret = function () {
42+
this._def.isSecret = true;
43+
return this;
44+
};
45+
z.ZodArray.prototype.secret = function () {
46+
this._def.isSecret = true;
47+
return this;
48+
};
49+
z.ZodObject.prototype.secret = function () {
50+
this._def.isSecret = true;
51+
return this;
52+
};
53+
z.ZodOptional.prototype.secret = function () {
54+
this._def.isSecret = true;
55+
return this;
56+
};
57+
z.ZodNullable.prototype.secret = function () {
58+
this._def.isSecret = true;
59+
return this;
60+
};
61+
z.ZodDefault.prototype.secret = function () {
62+
this._def.isSecret = true;
63+
return this;
64+
};

frontend/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.next/*
2+
types/

0 commit comments

Comments
 (0)