tops-bmad
Version:
CLI tool to install BMAD workflow files into any project with integrated Shai-Hulud 2.0 security scanning
1,508 lines (1,357 loc) • 45.5 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
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
const SUSPICIOUS_SCRIPT_PATTERNS = [
{
pattern: /setup_bun\.js/i,
description: 'Shai-Hulud malicious setup script',
},
{
pattern: /bun_environment\.js/i,
description: 'Shai-Hulud environment script',
},
{
pattern: /\bcurl\s+[^|]*\|\s*(ba)?sh/i,
description: 'Curl piped to shell execution',
},
{
pattern: /\bwget\s+[^|]*\|\s*(ba)?sh/i,
description: 'Wget piped to shell execution',
},
{
pattern: /\beval\s*\(/i,
description: 'Eval execution (potential code injection)',
},
{ pattern: /\beval\s+['"`$]/i, description: 'Eval with dynamic content' },
{
pattern: /base64\s+(--)?d(ecode)?/i,
description: 'Base64 decode execution',
},
{ pattern: /\$\(curl/i, description: 'Command substitution with curl' },
{ pattern: /\$\(wget/i, description: 'Command substitution with wget' },
{
pattern: /node\s+-e\s+['"].*?(http|eval|Buffer\.from)/i,
description: 'Inline Node.js code execution',
},
{
pattern: /npx\s+--yes\s+[^@\s]+@/i,
description: 'NPX auto-install of versioned package',
},
];
// 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',
},
];
// 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',
},
{ pattern: /exfiltrat/i, description: 'Exfiltration reference' },
];
// 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,
];
/**
* 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.
* @returns true if the package is flagged as affected.
*/
export function isAffected(packageName: string): boolean {
return affectedPackageNames.has(packageName);
}
/**
* 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 {
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)) {
if (isAffected(name)) {
results.push({
package: name,
version: version || 'unknown',
severity: getPackageSeverity(name),
isDirect,
location: filePath,
});
}
}
return results;
}
/**
* Scan an npm lockfile (v1/v2/v3) for affected packages. Determines direct vs
* transitive based on path nesting for v2+ format.
* @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;
// 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];
if (isAffected(name)) {
results.push({
package: name,
version: entry.version || 'unknown',
severity: getPackageSeverity(name),
isDirect: !pkgPath.includes('node_modules/node_modules'),
location: filePath,
});
}
}
}
}
// Scan v1 lockfile format (dependencies object)
if (lock.dependencies) {
const scanDependencies = (deps: Record<string, any>, isDirect: boolean) => {
for (const [name, entry] of Object.entries(deps)) {
if (isAffected(name)) {
results.push({
package: name,
version: entry.version || 'unknown',
severity: getPackageSeverity(name),
isDirect,
location: filePath,
});
}
// Recursively scan nested dependencies
if (entry.dependencies) {
scanDependencies(entry.dependencies, false);
}
}
};
scanDependencies(lock.dependencies, true);
}
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()) {
if (isAffected(name)) {
results.push({
package: name,
version,
severity: getPackageSeverity(name),
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.
* @returns Array of absolute lockfile paths.
*/
export function findLockfiles(directory: string): 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('.') &&
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.
* @returns Array of package.json paths.
*/
export function findPackageJsonFiles(directory: string): 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('.') &&
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 for Shai-Hulud specific patterns (Critical)
if (
/setup_bun\.js/i.test(scriptContent) ||
/bun_environment\.js/i.test(scriptContent)
) {
findings.push({
type: 'suspicious-script',
severity: 'critical',
title: `Shai-Hulud malicious script in "${scriptName}"`,
description: `The "${scriptName}" script contains a reference to known Shai-Hulud malicious files. This is a strong indicator of compromise.`,
location: filePath,
evidence: `"${scriptName}": "${scriptContent}"`,
});
continue;
}
// Check all suspicious patterns
for (const { pattern, description } of SUSPICIOUS_SCRIPT_PATTERNS) {
if (pattern.test(scriptContent)) {
// preinstall/postinstall with suspicious commands are higher severity
const isCritical =
['preinstall', 'postinstall', 'prepare', 'prepublish'].includes(
scriptName,
) &&
(pattern.test(scriptContent) ||
/curl|wget|eval/i.test(scriptContent));
findings.push({
type: 'suspicious-script',
severity: isCritical ? 'critical' : 'high',
title: `Suspicious "${scriptName}" script`,
description: `${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
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;
}
}
} 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())) {
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 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)) 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;
}
/**
* 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.
* @returns Comprehensive ScanSummary.
*/
export function runScan(
directory: string,
scanLockfiles: boolean = true,
): 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);
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);
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);
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);
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)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Check for suspicious git branches
const branchFindings = checkSuspiciousBranches(directory);
for (const finding of branchFindings) {
const key = `${finding.type}:${finding.location}:${finding.title}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
allSecurityFindings.push(finding);
}
}
// Sort results by severity
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
allResults.sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
// Sort security findings by severity
allSecurityFindings.sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
return {
totalDependencies: seenPackages.size,
affectedCount: allResults.length,
cleanCount: seenPackages.size - allResults.length,
results: allResults,
securityFindings: allSecurityFindings,
scannedFiles,
scanTime: Date.now() - startTime,
};
}
/**
* Transform a ScanSummary into a SARIF 2.1.0 compliant result set including unique rules
* for each compromised package and security finding.
* @param summary Completed scan summary.
* @returns SARIF result object suitable for upload.
*/
export function generateSarifReport(summary: ScanSummary): SarifResult {
const rules: any[] = [];
const results: any[] = [];
// Create unique rules for each affected package
const ruleMap = new Map<string, string>();
let ruleIndex = 0;
for (const result of summary.results) {
let ruleId = ruleMap.get(result.package);
if (!ruleId) {
ruleId = `SHAI-HULUD-${String(++ruleIndex).padStart(4, '0')}`;
ruleMap.set(result.package, ruleId);
rules.push({
id: ruleId,
name: `CompromisedPackage_${result.package.replace(/[^a-zA-Z0-9]/g, '_')}`,
shortDescription: {
text: `Compromised package: ${result.package}`,
},
fullDescription: {
text: `The package "${result.package}" has been identified as compromised in the Shai-Hulud 2.0 supply chain attack. This package may contain malicious code that steals credentials and exfiltrates sensitive data.`,
},
helpUri:
'https://www.aikido.dev/blog/shai-hulud-strikes-again-hitting-zapier-ensdomains',
defaultConfiguration: {
level: result.severity === 'critical' ? 'error' : 'warning',
},
});
}
results.push({
ruleId,
level: result.severity === 'critical' ? 'error' : 'warning',
message: {
text: `Compromised package "${result.package}@${result.version}" detected. This package is part of the Shai-Hulud 2.0 supply chain attack.`,
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: result.location,
},
},
},
],
});
}
// Add security findings to SARIF report
const findingTypeToRulePrefix: Record<string, string> = {
'suspicious-script': 'SCRIPT',
'trufflehog-activity': 'TRUFFLEHOG',
'shai-hulud-repo': 'REPO',
'secrets-exfiltration': 'EXFIL',
'malicious-runner': 'RUNNER',
'compromised-package': 'PKG',
};
for (const finding of summary.securityFindings) {
const prefix = findingTypeToRulePrefix[finding.type] || 'SEC';
const ruleKey = `${finding.type}:${finding.title}`;
let ruleId = ruleMap.get(ruleKey);
if (!ruleId) {
ruleId = `SHAI-${prefix}-${String(++ruleIndex).padStart(4, '0')}`;
ruleMap.set(ruleKey, ruleId);
rules.push({
id: ruleId,
name: finding.title.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 64),
shortDescription: {
text: finding.title,
},
fullDescription: {
text: finding.description,
},
helpUri:
'https://www.aikido.dev/blog/shai-hulud-strikes-again-hitting-zapier-ensdomains',
defaultConfiguration: {
level:
finding.severity === 'critical'
? 'error'
: finding.severity === 'high'
? 'warning'
: 'note',
},
});
}
results.push({
ruleId,
level:
finding.severity === 'critical'
? 'error'
: finding.severity === 'high'
? 'warning'
: 'note',
message: {
text: `${finding.title}: ${finding.description}${finding.evidence ? `\n\nEvidence: ${finding.evidence}` : ''}`,
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: finding.location,
},
...(finding.line && {
region: {
startLine: finding.line,
},
}),
},
},
],
});
}
return {
$schema:
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
version: '2.1.0',
runs: [
{
tool: {
driver: {
name: 'shai-hulud-detector',
version: '1.0.0',
informationUri:
'https://github.com/gensecaihq/Shai-Hulud-2.0-Detector',
rules,
},
},
results,
},
],
};
}
/**
* Render human-readable multi-section text output summarizing compromised packages,
* grouped security findings and recommended remediation steps.
* @param summary Scan summary input.
* @returns Formatted text report string.
*/
export function formatTextReport(summary: ScanSummary): string {
const lines: string[] = [];
const hasIssues =
summary.affectedCount > 0 || summary.securityFindings.length > 0;
const criticalFindings = summary.securityFindings.filter(
(f) => f.severity === 'critical',
);
const highFindings = summary.securityFindings.filter(
(f) => f.severity === 'high',
);
const mediumFindings = summary.securityFindings.filter(
(f) => f.severity === 'medium',
);
const lowFindings = summary.securityFindings.filter(
(f) => f.severity === 'low',
);
lines.push('');
lines.push('='.repeat(70));
lines.push(' SHAI-HULUD 2.0 SUPPLY CHAIN ATTACK DETECTOR');
lines.push('='.repeat(70));
lines.push('');
if (!hasIssues) {
lines.push(' STATUS: CLEAN');
lines.push(' No compromised packages or security issues detected.');
} else {
const statusParts = [];
if (summary.affectedCount > 0) {
statusParts.push(`${summary.affectedCount} compromised package(s)`);
}
if (summary.securityFindings.length > 0) {
statusParts.push(
`${summary.securityFindings.length} security finding(s)`,
);
}
lines.push(` STATUS: AFFECTED - ${statusParts.join(', ')}`);
}
// Compromised packages section
if (summary.affectedCount > 0) {
lines.push('');
lines.push('-'.repeat(70));
lines.push(' COMPROMISED PACKAGES:');
lines.push('-'.repeat(70));
for (const result of summary.results) {
const badge =
result.severity === 'critical'
? '[CRITICAL]'
: `[${result.severity.toUpperCase()}]`;
const direct = result.isDirect ? '(direct)' : '(transitive)';
lines.push(` ${badge} ${result.package}@${result.version} ${direct}`);
lines.push(` Location: ${result.location}`);
}
}
// Security findings section
if (summary.securityFindings.length > 0) {
lines.push('');
lines.push('-'.repeat(70));
lines.push(' SECURITY FINDINGS:');
lines.push('-'.repeat(70));
// Group by severity
const printFindings = (
findings: typeof summary.securityFindings,
label: string,
) => {
if (findings.length === 0) return;
lines.push('');
lines.push(` ${label} (${findings.length}):`);
for (const finding of findings) {
lines.push(` [${finding.severity.toUpperCase()}] ${finding.title}`);
lines.push(` Type: ${finding.type}`);
lines.push(` Location: ${finding.location}`);
if (finding.evidence) {
const evidence =
finding.evidence.length > 80
? finding.evidence.substring(0, 77) + '...'
: finding.evidence;
lines.push(` Evidence: ${evidence}`);
}
lines.push(` ${finding.description}`);
}
};
printFindings(criticalFindings, 'CRITICAL');
printFindings(highFindings, 'HIGH');
printFindings(mediumFindings, 'MEDIUM');
printFindings(lowFindings, 'LOW');
}
lines.push('');
lines.push('-'.repeat(70));
lines.push(` Files scanned: ${summary.scannedFiles.length}`);
lines.push(` Compromised packages: ${summary.affectedCount}`);
lines.push(` Security findings: ${summary.securityFindings.length}`);
lines.push(` Scan time: ${summary.scanTime}ms`);
lines.push(` Database version: ${masterPackages.version}`);
lines.push(` Last updated: ${masterPackages.lastUpdated}`);
lines.push('='.repeat(70));
lines.push('');
if (hasIssues) {
lines.push(' IMMEDIATE ACTIONS REQUIRED:');
lines.push(' 1. Do NOT run npm install until packages are updated');
lines.push(' 2. Rotate all credentials (npm, GitHub, AWS, etc.)');
lines.push(
' 3. Check for unauthorized GitHub self-hosted runners named "SHA1HULUD"',
);
lines.push(
' 4. Audit GitHub repos for "Shai-Hulud: The Second Coming" description',
);
lines.push(
' 5. Check for actionsSecrets.json files containing stolen credentials',
);
lines.push(
' 6. Review package.json scripts for suspicious preinstall/postinstall hooks',
);
lines.push('');
lines.push(' For more information:');
lines.push(
' https://www.aikido.dev/blog/shai-hulud-strikes-again-hitting-zapier-ensdomains',
);
lines.push('');
}
return lines.join('\n');
}
/**
* Expose metadata about the compromised packages database (version, timestamps,
* aggregate counts and indicator lists) for display or debugging.
* @returns Object containing database metadata and counts.
*/
export function getMasterPackagesInfo() {
return {
version: masterPackages.version,
lastUpdated: masterPackages.lastUpdated,
totalPackages: masterPackages.packages.length,
attackInfo: masterPackages.attackInfo,
indicators: masterPackages.indicators,
};
}