hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
334 lines (287 loc) • 10.1 kB
text/typescript
import type {
Envelope,
Event,
EventItem,
Exception,
StackFrame,
Stacktrace,
} from "@sentry/core";
import * as path from "node:path";
// This file is executed in a subprocess, and it is always run in this context.
// Therefore, it is acceptable to avoid using dynamic imports and instead import all necessary modules at the beginning.
import * as czech from "ethereum-cryptography/bip39/wordlists/czech.js";
import * as english from "ethereum-cryptography/bip39/wordlists/english.js";
import * as french from "ethereum-cryptography/bip39/wordlists/french.js";
import * as italian from "ethereum-cryptography/bip39/wordlists/italian.js";
import * as japanese from "ethereum-cryptography/bip39/wordlists/japanese.js";
import * as korean from "ethereum-cryptography/bip39/wordlists/korean.js";
import * as simplifiedChinese from "ethereum-cryptography/bip39/wordlists/simplified-chinese.js";
import * as SPANISH from "ethereum-cryptography/bip39/wordlists/spanish.js";
import * as traditionalChinese from "ethereum-cryptography/bip39/wordlists/traditional-chinese.js";
import { ANONYMIZED_PATH, anonymizeUserPaths } from "./anonymize-paths.js";
import { GENERIC_SERVER_NAME } from "./constants.js";
interface WordMatch {
index: number;
word: string;
}
export type AnonymizeEnvelopeResult =
| { success: true; envelope: Envelope }
| { success: false; error: string };
export type AnonymizeEventResult =
| { success: true; event: Event }
| { success: false; error: string };
const ANONYMIZED_MNEMONIC = "<mnemonic>";
const MNEMONIC_PHRASE_LENGTH_THRESHOLD = 7;
const MINIMUM_AMOUNT_OF_WORDS_TO_ANONYMIZE = 4;
export class Anonymizer {
readonly #configPath?: string;
constructor(configPath?: string) {
this.#configPath = configPath;
}
/**
* Anonymizes the events in the envelope in place, modifying the envelope.
*/
public async anonymizeEventsFromEnvelope(
envelope: Envelope,
): Promise<AnonymizeEnvelopeResult> {
for (const item of envelope[1]) {
if (item[0].type === "event") {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions
-- We know that the item is an event item */
const eventItem = item as EventItem;
const anonymizedEvent = await this.anonymizeEvent(eventItem[1]);
if (anonymizedEvent.success) {
eventItem[1] = anonymizedEvent.event;
} else {
return { success: false, error: anonymizedEvent.error };
}
}
}
return { success: true, envelope };
}
/**
* Given a sentry serialized exception
* (https://develop.sentry.dev/sdk/event-payloads/exception/), return an
* anonymized version of the event.
*/
public async anonymizeEvent(event: Event): Promise<AnonymizeEventResult> {
const result: Event = {
event_id: event.event_id,
platform: event.platform,
timestamp: event.timestamp,
extra: event.extra,
release: event.release,
contexts: event.contexts,
sdk: event.sdk,
level: event.level,
server_name: GENERIC_SERVER_NAME,
environment: event.environment,
};
if (event.exception !== undefined && event.exception.values !== undefined) {
const anonymizedExceptions = await this.#anonymizeExceptions(
event.exception.values,
);
result.exception = {
values: anonymizedExceptions,
};
}
return { success: true, event: result };
}
/**
* Return the anonymized filename and a boolean indicating if the content of
* the file should be anonymized
*/
public async anonymizeFilename(filename: string): Promise<{
anonymizedFilename: string;
anonymizeContent: boolean;
}> {
if (filename === this.#configPath) {
return {
anonymizedFilename: path.basename(filename),
anonymizeContent: true,
};
}
const anonymizedFilename = anonymizeUserPaths(filename);
// We anonymize the content if the file path is entirely anonymized, or if
// it wasn't anonymized because it's just a basename
const anonymizeContent =
anonymizedFilename === ANONYMIZED_PATH ||
path.basename(filename) === filename;
return {
anonymizedFilename,
anonymizeContent,
};
}
public anonymizeErrorMessage(errorMessage: string): string {
errorMessage = this.#anonymizeMnemonic(errorMessage);
// We intentionally replace the config path with its own
// anonymized token, to help differentiate the config
// file in exception reports while keeping it anonymized.
if (this.#configPath !== undefined) {
errorMessage = errorMessage.replaceAll(
this.#configPath,
"<hardhat-config-file>",
);
}
// hide hex strings of 20 chars or more
const hexRegex = /(0x)?[0-9A-Fa-f]{20,}/g;
return anonymizeUserPaths(errorMessage).replace(hexRegex, (match) =>
match.replace(/./g, "x"),
);
}
async #anonymizeExceptions(exceptions: Exception[]): Promise<Exception[]> {
const anonymizedExceptions = await Promise.all(
exceptions.map((exception) => this.#anonymizeException(exception)),
);
return anonymizedExceptions;
}
async #anonymizeException(value: Exception): Promise<Exception> {
const result: Exception = {
type: value.type,
mechanism: value.mechanism,
};
if (value.value !== undefined) {
result.value = this.anonymizeErrorMessage(value.value);
}
if (value.stacktrace !== undefined) {
result.stacktrace = await this.#anonymizeStacktrace(value.stacktrace);
}
return result;
}
async #anonymizeStacktrace(stacktrace: Stacktrace): Promise<Stacktrace> {
if (stacktrace.frames !== undefined) {
const anonymizedFrames = await this.#anonymizeFrames(stacktrace.frames);
return {
frames: anonymizedFrames,
};
}
return {};
}
async #anonymizeFrames(frames: StackFrame[]): Promise<StackFrame[]> {
const anonymizedFrames = await Promise.all(
frames.map(async (frame) => {
return await this.#anonymizeFrame(frame);
}),
);
return anonymizedFrames;
}
async #anonymizeFrame(frame: StackFrame): Promise<StackFrame> {
const result: StackFrame = {
lineno: frame.lineno,
colno: frame.colno,
function: frame.function,
};
let anonymizeContent = true;
if (frame.filename !== undefined) {
const anonymizationResult = await this.anonymizeFilename(frame.filename);
result.filename = anonymizationResult.anonymizedFilename;
anonymizeContent = anonymizationResult.anonymizeContent;
}
if (!anonymizeContent) {
result.context_line = frame.context_line;
result.pre_context = frame.pre_context;
result.post_context = frame.post_context;
result.vars = frame.vars;
}
return result;
}
#anonymizeMnemonic(errorMessage: string): string {
const matches = getAllWordMatches(errorMessage);
// If there are enough consecutive words, there's a good chance of there being a mnemonic phrase
if (matches.length < MNEMONIC_PHRASE_LENGTH_THRESHOLD) {
return errorMessage;
}
const mnemonicWordList = [
czech,
english,
french,
italian,
japanese,
korean,
simplifiedChinese,
SPANISH,
traditionalChinese,
]
.map((wordlistModule) => wordlistModule.wordlist)
.flat();
let anonymizedMessage = errorMessage.slice(0, matches[0].index);
// Determine all mnemonic phrase maximal fragments.
// We check sequences of n consecutive words just in case there is a typo
let wordIndex = 0;
while (wordIndex < matches.length) {
const maximalPhrase = getMaximalMnemonicPhrase(
matches,
errorMessage,
wordIndex,
mnemonicWordList,
);
if (maximalPhrase.length >= MINIMUM_AMOUNT_OF_WORDS_TO_ANONYMIZE) {
const lastAnonymizedWord = maximalPhrase[maximalPhrase.length - 1];
const nextWordIndex =
wordIndex + maximalPhrase.length < matches.length
? matches[wordIndex + maximalPhrase.length].index
: errorMessage.length;
const sliceUntilNextWord = errorMessage.slice(
lastAnonymizedWord.index + lastAnonymizedWord.word.length,
nextWordIndex,
);
anonymizedMessage += `${ANONYMIZED_MNEMONIC}${sliceUntilNextWord}`;
wordIndex += maximalPhrase.length;
} else {
const thisWord = matches[wordIndex];
const nextWordIndex =
wordIndex + 1 < matches.length
? matches[wordIndex + 1].index
: errorMessage.length;
const sliceUntilNextWord = errorMessage.slice(
thisWord.index,
nextWordIndex,
);
anonymizedMessage += sliceUntilNextWord;
wordIndex++;
}
}
return anonymizedMessage;
}
}
function getMaximalMnemonicPhrase(
matches: WordMatch[],
originalMessage: string,
startIndex: number,
mnemonicWordList: string[],
): WordMatch[] {
const maximalPhrase: WordMatch[] = [];
for (let i = startIndex; i < matches.length; i++) {
const thisMatch = matches[i];
if (!mnemonicWordList.includes(thisMatch.word)) {
break;
}
if (maximalPhrase.length > 0) {
// Check that there's only whitespace until this word.
const lastMatch = maximalPhrase[maximalPhrase.length - 1];
const lastIndex = lastMatch.index + lastMatch.word.length;
const sliceBetweenWords = originalMessage.slice(
lastIndex,
thisMatch.index,
);
if (!/\s+/u.test(sliceBetweenWords)) {
break;
}
}
maximalPhrase.push(thisMatch);
}
return maximalPhrase;
}
function getAllWordMatches(errorMessage: string) {
const matches: WordMatch[] = [];
const re = /\p{Letter}+/gu;
let match = re.exec(errorMessage);
while (match !== null) {
matches.push({
word: match[0],
index: match.index,
});
match = re.exec(errorMessage);
}
return matches;
}