hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
502 lines (420 loc) • 14.8 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"),
);
}
public filterOutEventsWithExceptionsNotRaisedByHardhat(
envelope: Envelope,
): Envelope {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions --
We are just filtering events in place, not changing any type */
envelope[1] = envelope[1].filter((item) => {
if (item[0].type !== "event") {
return true;
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions
-- We know that the item is an event item */
const eventItem = item as EventItem;
return this.raisedByHardhat(eventItem[1]);
}) as any;
return envelope;
}
public raisedByHardhat(event: Event): boolean {
const exceptions = event?.exception?.values;
if (exceptions === undefined) {
// if we can't prove that the exception doesn't come from hardhat,
// we err on the side of reporting the error
return true;
}
const originalException = exceptions[exceptions.length - 1];
if (!this.#isErrorMessageAllowed(originalException)) {
return false;
}
const frames = originalException?.stacktrace?.frames;
if (frames === undefined) {
return true;
}
for (const frame of frames.slice().reverse()) {
if (frame.filename === undefined) {
continue;
}
// We don't report errors from the Hardhat.config file
if (
this.#configPath !== undefined &&
this.#configPath.includes(frame.filename)
) {
return false;
}
if (this.#isPackageFile(frame.filename)) {
// We don't report errors from the ignored package list e.g. `ethers`
// even when buried as a subpackage of a hardhat package
if (this.#errorRaisedByPackageToIgnore(frame.filename)) {
return false;
}
// We report errors from Hardhat packages, we exclude
// those from non-hardhat packages
return this.#isHardhatFile(frame.filename);
}
// Error originating not in packages, but in the user project
// should be filtered.
if (this.#isUserProjectFile(frame.filename)) {
return false;
}
// Otherwise look at the next frame up
}
// if we didn't find any hardhat frame, we don't report the error
return false;
}
#isErrorMessageAllowed(originalException: Exception): boolean {
const exceptionType = originalException.type;
const exceptionMessage = originalException.value;
// Without an exception message, we can't filter so allow it
if (exceptionMessage === undefined) {
return true;
}
// Filter out required not defined in ES Modules errors
if (
exceptionType === "ReferenceError" &&
exceptionMessage ===
"require is not defined in ES module scope, you can use import instead"
) {
return false;
}
// Filter out cannot find package when importing errors
if (
exceptionType === "Error" &&
/^Cannot find package '([^']+)' imported from .+$/.test(exceptionMessage)
) {
return false;
}
// Filter out cannot find module errors
if (
exceptionType === "Error" &&
/^Cannot find module '([^']+)'/.test(exceptionMessage)
) {
return false;
}
// Filter out "require() cannot be sued on an ESM graph"
if (
exceptionType === "Error" &&
exceptionMessage.startsWith(
"require() cannot be used on an ESM graph with top-level await. Use import() instead.",
)
) {
return false;
}
return true;
}
#errorRaisedByPackageToIgnore(filename: string): boolean {
// List of external packages that we don't want to report errors from
const pkgsToIgnore: string[] = [
path.join("node_modules", "@ethersproject"),
];
// Match path separators both for Windows and Unix
const pkgs = filename.match(/node_modules[\/\\][^\/\\]+/g);
if (pkgs === null) {
return false;
}
const errorSourcePkg = pkgs[pkgs.length - 1];
return pkgsToIgnore.includes(errorSourcePkg);
}
#isHardhatFile(filename: string): boolean {
const nomicFoundationPath = path.join("node_modules", "@nomicfoundation");
const ignoredOrgPath = path.join("node_modules", "@ignored");
const hardhatPath = path.join("node_modules", "hardhat");
filename = filename.toLowerCase();
return (
filename.includes(nomicFoundationPath) ||
filename.includes(ignoredOrgPath) ||
filename.includes(hardhatPath)
);
}
#isPackageFile(filename: string): boolean {
return filename.includes("node_modules");
}
#isUserProjectFile(filename: string): boolean {
const anonymizedUserPath = anonymizeUserPaths(filename);
return anonymizedUserPath === ANONYMIZED_PATH;
}
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 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;
}