- POST /approvals and PATCH /approvals/{id} require a console admin session
(EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
91 lines
3.9 KiB
TypeScript
91 lines
3.9 KiB
TypeScript
import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts";
|
|
import pino, { type Logger } from "pino";
|
|
|
|
// 键表与 URL query / 请求头 / Bearer 规则与 Agent 的 redactForModel 共用一份(@umt/contracts redact.ts):
|
|
// 键名规范化后命中 passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / cookie / x-api-key / *token …
|
|
// 即整体替换(数值型如 token 计数除外);其余字符串按 redactString 处理。
|
|
export { REDACTED, redactString };
|
|
|
|
const sensitive = (k: string, v: unknown) => isSensitiveKey(k) && typeof v !== "number" && v != null;
|
|
|
|
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
|
|
export function redact(value: unknown, depth = 0): unknown {
|
|
if (depth > 8) return value;
|
|
if (typeof value === "string") return redactString(value);
|
|
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
|
|
if (value instanceof Error) return redactError(value, depth);
|
|
if (value && typeof value === "object") {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
if (sensitive(k, v)) out[k] = REDACTED;
|
|
else out[k] = redact(v, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Error 的 message/stack 是不可枚举属性,按普通对象处理会变成 `{}`;
|
|
* 这里先按 pino 标准形状(type/message/stack + 自有字段 + cause)展开再脱敏。
|
|
*/
|
|
function redactError(err: Error, depth: number): Record<string, unknown> {
|
|
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
|
|
if (err.stack) out.stack = redactString(err.stack);
|
|
for (const [k, v] of Object.entries(err)) {
|
|
out[k] = sensitive(k, v) ? REDACTED : redact(v, depth + 1);
|
|
}
|
|
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
|
|
return out;
|
|
}
|
|
|
|
export interface LoggerOptions {
|
|
level?: string;
|
|
pretty?: boolean;
|
|
/** 测试用:自定义输出流 */
|
|
destination?: pino.DestinationStream;
|
|
}
|
|
|
|
export function createLogger(opts: LoggerOptions = {}): Logger {
|
|
const options: pino.LoggerOptions = {
|
|
level: opts.level ?? "info",
|
|
formatters: {
|
|
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
|
|
log: (obj) => redact(obj) as Record<string, unknown>,
|
|
// 根 logger 的 base 绑定;child() 的绑定 pino 会跳过这里,由 redactChildBindings 处理
|
|
bindings: (bindings) => redact(bindings) as Record<string, unknown>,
|
|
},
|
|
// err 已由 formatters.log 按 message/stack 展开并脱敏;默认 err 序列化器会把 type 改写成 "Object"
|
|
serializers: { err: (v: unknown) => v },
|
|
hooks: {
|
|
// msg 文本也脱敏
|
|
logMethod(args, method) {
|
|
const cleaned = args.map((a) => (typeof a === "string" ? redactString(a) : a)) as typeof args;
|
|
return method.apply(this, cleaned);
|
|
},
|
|
},
|
|
};
|
|
if (opts.destination) return redactChildBindings(pino(options, opts.destination));
|
|
if (opts.pretty) {
|
|
return redactChildBindings(
|
|
pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }),
|
|
);
|
|
}
|
|
// 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
|
|
return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true })));
|
|
}
|
|
|
|
/**
|
|
* pino 的 child() 会把 formatters.bindings 重置为恒等函数,绑定字段(如 `child({ password })`)
|
|
* 原样写进每一行。这里在根实例上覆盖 child:子孙 logger 经原型链继承,绑定先脱敏再交给 pino。
|
|
*/
|
|
function redactChildBindings(logger: Logger): Logger {
|
|
type ChildFn = (this: Logger, bindings: pino.Bindings, options?: pino.ChildLoggerOptions) => Logger;
|
|
const original = logger.child as unknown as ChildFn;
|
|
const child: ChildFn = function (bindings, options) {
|
|
return original.call(this, redact(bindings) as pino.Bindings, options);
|
|
};
|
|
logger.child = child as unknown as Logger["child"];
|
|
return logger;
|
|
}
|