forked from Prompt-Hash-Stellar/prompt-hash
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauditTrail.ts
More file actions
136 lines (126 loc) · 4.98 KB
/
Copy pathauditTrail.ts
File metadata and controls
136 lines (126 loc) · 4.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import { createHash } from "crypto";
import { AuditLog, AuditAction, AuditResult } from "../models/AuditLog";
// Simple fallback logger object to handle logging without cross-boundary imports
// Simple fallback logger object to handle logging without cross-boundary imports
const logger = {
info: (logFields: any, msg: string) => console.log(msg, logFields),
warn: (logFields: any, msg: string) => console.warn(msg, logFields),
error: (logFields: any, msg: string) => console.error(msg, logFields)
};
/**
* One-way SHA-256 hash of a Stellar wallet address.
* Stored in audit logs instead of the raw address so logs are
* privacy-safe by default while still allowing incident correlation (#224).
*
* @param address - Raw Stellar account ID (G…)
* @returns Lowercase hex digest
*/
export function hashWalletAddress(address: string): string {
return createHash("sha256").update(address.toLowerCase()).digest("hex");
}
/**
* Structured fields logged for every unlock attempt.
*
* Fields:
* action - AuditAction enum value (e.g. "unlock_attempt", "access_granted")
* result - AuditResult enum value ("success" | "failure" | "denied")
* requestId - UUID from withObservability middleware; links log → DB row
* walletHash - SHA-256(walletAddress.toLowerCase()); never the raw address
* promptId - Numeric prompt ID from the contract
* reason - Human-readable explanation for denials/failures (no sensitive content)
*
* NEVER include: plaintext, signedMessage, challengeSecret, privateKey, or clientIp
* in structured logs. Those are either redacted by the pino transport or must not
* appear at all.
*/
export interface AuditEventParams {
action: AuditAction;
result: AuditResult;
promptId?: string | null;
/** Raw Stellar wallet address — hashed before logging or DB persistence. */
walletAddress?: string | null;
requestId?: string | null;
clientIp?: string | null;
reason?: string | null;
}
/**
* Persist a structured audit event and emit a pino log entry at the
* appropriate level.
*
* Log levels (#224):
* info — successful unlock or expected denial (no on-chain access)
* warn — validation failure (bad signature, expired challenge)
* error — unexpected internal error during the unlock flow
*
* Fire-and-forget: DB errors are caught and logged to stderr; they never
* propagate so a storage hiccup cannot block a legitimate unlock.
*/
export async function recordAuditEvent(params: AuditEventParams): Promise<void> {
const walletHash = params.walletAddress
? hashWalletAddress(params.walletAddress)
: null;
// Structured pino log — wallet address is intentionally absent; only the
// hash is emitted so the log stream never carries PII (#224).
const logFields = {
action: params.action,
result: params.result,
requestId: params.requestId ?? undefined,
walletHash: walletHash ?? undefined,
promptId: params.promptId ?? undefined,
reason: params.reason ?? undefined,
};
if ((params.result as string) === "failure" || (params.result as string) === "denied") {
logger.warn(logFields, `audit: ${params.action} → ${params.result}`);
} else {
logger.info(logFields, `audit: ${params.action} → ${params.result}`);
}
try {
await AuditLog.create({
action: params.action,
result: params.result,
promptId: params.promptId ?? null,
// Store the hash, not the raw address, for DB-level privacy (#224).
walletAddress: walletHash,
requestId: params.requestId ?? null,
clientIp: params.clientIp ?? null,
reason: params.reason ?? null,
});
} catch (err) {
// Do not let audit failures surface to callers.
logger.error(
{ action: params.action, requestId: params.requestId, err: err instanceof Error ? err.message : String(err) },
"audit: failed to persist audit event to DB",
);
}
}
/**
* Query audit events for incident review. Returns the most recent `limit`
* events matching the filter, oldest-first within the result set.
*
* Pass walletAddress as a raw address — it will be hashed before querying
* so the caller never needs to know the storage representation.
*/
export async function queryAuditEvents(filter: {
walletAddress?: string;
promptId?: string;
action?: AuditAction;
result?: AuditResult;
since?: Date;
until?: Date;
limit?: number;
}) {
const query: Record<string, unknown> = {};
if (filter.walletAddress) query.walletAddress = hashWalletAddress(filter.walletAddress);
if (filter.promptId) query.promptId = filter.promptId;
if (filter.action) query.action = filter.action;
if (filter.result) query.result = filter.result;
if (filter.since || filter.until) {
query.createdAt = {} as Record<string, Date>;
if (filter.since) (query.createdAt as Record<string, Date>)["$gte"] = filter.since;
if (filter.until) (query.createdAt as Record<string, Date>)["$lte"] = filter.until;
}
return AuditLog.find(query)
.sort({ createdAt: -1 })
.limit(filter.limit ?? 100)
.lean();
}