Official Node.js SDK for mlab.sh, the threat intelligence platform.
It covers what the mlab CLI does: IP, URL, email, hash, MAC, phone and crypto lookups, IOC extraction, shell script analysis, domain scans (v2 report), SSL certificates, quotas, file analysis, CVE search, SBOM and package scans on vuln.mlab.sh, and threat actors on actors.mlab.sh.
- TypeScript, ESM and CommonJS builds, with types
- Node.js 18 or later, native
fetch, no runtime dependencies - Typed errors, quota headers,
AbortSignalon every call - Helpers that wait for a domain or file scan to finish
This package is a library. For a command line, install mlab-cli.
npm install @mlabsh/mlab-shimport { MlabClient } from '@mlabsh/mlab-sh';
const mlab = new MlabClient(); // reads MLAB_API_KEY
const ip = await mlab.lookupIp('8.8.8.8');
console.log(ip.country, ip.isp);CommonJS works the same way:
const { MlabClient } = require('@mlabsh/mlab-sh');const mlab = new MlabClient({
apiKey: 'mlab_…', // default: MLAB_API_KEY
baseUrl: 'https://mlab.sh', // site root of a self-hosted instance; default: MLAB_HOSTNAME
cveBaseUrl: 'https://vuln.mlab.sh', // default: MLAB_CVE_HOSTNAME
actorsBaseUrl: 'https://actors.mlab.sh', // default: MLAB_ACTORS_HOSTNAME
vulnToken: '…', // personal vuln.mlab.sh token, raises the SBOM quota; default: MLAB_VULN_TOKEN
timeoutMs: 60_000, // per request
maxRetries: 2, // extra attempts for GET requests
userAgent: 'my-soar/2.1', // appended to mlab-sh-node/1.0.0
fetch, // custom fetch (proxy, tests)
});The environment variables are the ones mlab-cli reads. The SDK does not read the CLI's config file or its profiles. Pass the values in, or set the variables.
The key is sent as Authorization: token <key> to mlab.sh only. It is never sent to vuln.mlab.sh,
actors.mlab.sh or GitHub, and console.log(client) and JSON.stringify(client) do not show it.
Create a key in the organization settings on mlab.sh. Each key carries scopes: lookups need
scan.lookup, domain scans scan.domain, file and shell script analysis scan.file.
Keys expire after 90 days by default.
Lookups also work without a key, at the anonymous quota.
Every method takes a final options object with signal:
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);
await mlab.lookupUrl('https://example.com', { signal: ctrl.signal });The promise rejects with the signal's reason, and no retry follows.
const me = await mlab.whoami(); // { organization, plan, auth: 'api_key' }
const ip = await mlab.getLimit('ip'); // { scan_type: 'ip', remaining, total }
const all = await mlab.getLimits(); // { domain, ip, file, crypto }
await mlab.lookupIp('1.1.1.1');
mlab.lastQuota; // { limit, remaining, resource: 'ip', windowSeconds: 86400 }, from X-RateLimit-*lastQuota is updated by every response that carries the X-RateLimit-* headers. The server sends
them on scans made with an API key.
await mlab.lookupIp('8.8.8.8');
await mlab.lookupUrl('https://example.com/login');
await mlab.lookupUrl('https://bit.ly/x', { resolve: true }); // the server fetches the link and follows redirects
await mlab.lookupHash('d41d8cd98f00b204e9800998ecf8427e');
await mlab.lookupHashes(['d41d8…', 'da39a3…']); // bulk: results, invalid, not_queryable…
await mlab.lookupEmail('abuse@example.com');
await mlab.lookupPhone('+33612345678'); // E.164, with the +
await mlab.lookupMac('00:11:22:33:44:55');
await mlab.lookupCrypto('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq');
await mlab.lookupCrypto('D8vFz…', { chain: 'doge' }); // override detection only when needed
await mlab.lookupCryptoAddresses(['0x742d…', '1A1zP1…']); // bulkimport { detectTarget } from '@mlabsh/mlab-sh';
detectTarget('CVE-2024-3094'); // 'cve'
const { target, result } = await mlab.search('8.8.8.8'); // runs the matching lookup
mlab.reportUrl('example.com'); // 'https://mlab.sh/domain/example.com'search routes a domain to its last report. It never starts a scan.
const text = await readFile('report.txt', 'utf8');
const r = await mlab.extractIocs(text); // up to 1 MB, defanged forms included
r.iocs.ip; // [{ value, link }]
const sms = await mlab.extractIocs(message, { risk: 'fast', country: 'fr' }); // SMS threat scoring
sms.risk?.band; // 'clean' | 'suspect' | 'high' | 'critical'risk: 'deep' also unmasks short links and checks raw-IP hosts. It makes outbound requests, so keep it
off a blocking path.
const report = await mlab.analyzeBash(await readFile('install.sh'), { filename: 'install.sh' });
report.suspicious;
await mlab.getBashReport(report.file.sha256); // read a stored report againconst capture = await mlab.captureNetwork('https://example.com'); // a real browser loads the page; slow
capture.requests?.filter((r) => r.failed);const certs = await mlab.getSsl('example.com'); // [{ common_name, issuer_name, not_before, not_after, … }]await mlab.scanDomain('example.com'); // spends one domain scan
const report = await mlab.waitForDomainScan('example.com', {
timeoutMs: 15 * 60_000, // default
onProgress: (s) => console.log(s.status, s.done, s.total),
});
report.schema; // 'domain-report/2'
report.score; // { value: 72, grade: 'C' }
report.findings.filter((f) => f.severity === 'high');The wait helper polls every 2 s, then backs off to 15 s. It tolerates up to 5 transient errors in a row.
It throws ScanFailedError when the scan failed and ScanTimeoutError when the time runs out. A scan that
timed out keeps running on the server, so you can read it later:
await mlab.getDomainStatus('example.com'); // { status: 'pending' | 'scanning' | 'success', … }
await mlab.getDomainReport('example.com'); // v2 report
await mlab.getDomainResults('example.com'); // legacy v1 payload, frozen upstreamconst up = await mlab.uploadFile('./sample.exe'); // a path, a Blob, a Uint8Array or an ArrayBuffer
const results = await mlab.waitForFileScan(up.sha256, {
onProgress: (r) => console.log(`${r.tools_done}/${r.tools_total}`),
});
results.status; // 'completed' or 'partial'
results.findings;
const out = await mlab.getFileToolOutput(up.sha256, 'strings'); // one tool's raw output
const data = out.is_json ? JSON.parse(out.output) : out.output;The upload goes to <baseUrl>/upload/file, at the site root, not under /api/v1. The size limit is 10 MB on
the Free plan and 100 MB on paid plans. A failed analysis throws ScanFailedError.
No key needed.
await mlab.cve.search('openssl', { severity: 'CRITICAL', kevOnly: true, minCvss: 9, page: 0, limit: 20 });
await mlab.cve.latest(); // last 7 days
await mlab.cve.get('CVE-2024-3094'); // CVSS, EPSS, KEV, references
await mlab.cve.advisories({ cve: 'CVE-2024-3094' });
await mlab.cve.vendorMonths('microsoft', 2025); // { months: [12 counts] }
await mlab.cve.stats();
await mlab.cve.sources();
await mlab.cve.dump({ dateStart: '2025-01-01', minCvss: 7 });
const csv = await mlab.cve.export('openssl', { format: 'csv' }); // or 'rss'; returns textFilters: severity, dateStart, dateEnd, vendor, cwe, minCvss, kevOnly, exact.
const scan = await mlab.vuln.scanManifest(await readFile('package-lock.json'), { filename: 'package-lock.json' });
await mlab.vuln.scanManifestUrl('https://raw.githubusercontent.com/org/repo/main/Cargo.lock');
await mlab.vuln.query('pkg:npm/lodash', { version: '4.17.20' });
await mlab.vuln.query('crates.io/time');The server detects the format: npm, Cargo, pip, Go, Composer, Gemfile, CycloneDX and others. Pass format
to force one. The scan cooldown answers with a short 429, which the SDK waits out.
await mlab.actors.list({ origin: 'Russia', sector: 'Government', limit: 50 }); // { items, total, offset, limit }
await mlab.actors.get('apt28');
await mlab.actors.byCve('CVE-2023-23397');
await mlab.actors.stix('apt28'); // STIX 2.1 bundle
await mlab.actors.export({ format: 'jsonl' }); // or 'csv'; returns textData: ETDA Threat Group Cards and MITRE ATT&CK, CC BY-NC-SA 4.0.
const { modules } = await mlab.modules.available(); // UniFi, MikroTik, Proxmox, Cloudflare, Scaleway…The modules are standalone binaries. Install them with mlab module install <name>.
Every error is an MlabError, with kind, status, body, url and quota:
| Class | When |
|---|---|
AuthenticationError |
401: no key, or a key that is unknown, expired or revoked |
ForbiddenError |
403: the key lacks a scope (err.scope, e.g. scan.domain) or the route is closed to keys; the server message is kept |
QuotaError |
429, or a "limit reached" 400 (err.retryAfter in seconds) |
InputError |
the value was refused as malformed |
NotFoundError |
nothing on record (no scan for this domain, unknown CVE…) |
MaintenanceError |
the platform is in maintenance |
NetworkError |
no HTTP answer: DNS, TLS, connection refused, timeout |
ScanFailedError |
a domain or file scan ended in failed |
ScanTimeoutError |
a wait helper gave up |
import { QuotaError, ForbiddenError } from '@mlabsh/mlab-sh';
try {
await mlab.scanDomain('example.com');
} catch (e) {
if (e instanceof QuotaError) console.log(`retry in ${e.retryAfter}s`, e.quota);
else if (e instanceof ForbiddenError) console.log(`add the ${e.scope} permission to the key`);
else throw e;
}GET requests are retried up to twice on network errors and 5xx, and on a 429 whose Retry-After is
30 s or less. POST requests launch metered scans, so they are never replayed.
npm ci
npm run typecheck
npm run lint
npm testIntegration tests run against a real instance, with read-only calls only:
MLAB_INTEGRATION_URL=http://localhost:8080 MLAB_API_KEY=… npm run test:integrationSee LICENSE.