|
#!/usr/bin/env node |
|
// AUTO-GENERATED — do not edit directly. |
|
// Source: https://github.com/your-org/axios-ado-scanner |
|
// Rebuild: npm run build:standalone |
|
// |
|
// Requirements: Node.js >= 14. No install required. |
|
// Usage: node workstation-audit-standalone.js |
|
// node workstation-audit-standalone.js --output my-report.csv |
|
// node workstation-audit-standalone.js --root C:\evidence\host-export |
|
'use strict'; |
|
|
|
const fs = require('node:fs'); |
|
const https = require('node:https'); |
|
const os = require('node:os'); |
|
const path = require('node:path'); |
|
const readline = require('node:readline'); |
|
|
|
// ─── incident-patterns.js ─────────────────────────────────────────────────── |
|
const INCIDENT_PATTERNS = [ |
|
{ id: 'axios-1.14.1', label: 'axios@1.14.1', category: 'package', severity: 'critical', regex: /axios@1\.14\.1\b/i }, |
|
{ id: 'axios-0.30.4', label: 'axios@0.30.4', category: 'package', severity: 'critical', regex: /axios@0\.30\.4\b/i }, |
|
{ id: 'plain-crypto-js-4.2.1', label: 'plain-crypto-js@4.2.1', category: 'package', severity: 'critical', regex: /plain-crypto-js@4\.2\.1\b/i }, |
|
{ id: 'sfrclak-domain', label: 'sfrclak.com', category: 'network', severity: 'critical', regex: /\bsfrclak\.com\b/i }, |
|
{ id: 'sfrclak-url', label: 'sfrclak.com:8000/6202033', category: 'network', severity: 'critical', regex: /https?:\/\/sfrclak\.com:8000\/6202033\b/i }, |
|
{ id: 'product0', label: 'packages.npm.org/product0', category: 'network', severity: 'critical', regex: /packages\.npm\.org\/product0\b/i }, |
|
{ id: 'product1', label: 'packages.npm.org/product1', category: 'network', severity: 'critical', regex: /packages\.npm\.org\/product1\b/i }, |
|
{ id: 'product2', label: 'packages.npm.org/product2', category: 'network', severity: 'critical', regex: /packages\.npm\.org\/product2\b/i }, |
|
{ id: 'windows-wt', label: 'wt.exe', category: 'filesystem', severity: 'high', regex: /(?:^|[\\/])wt\.exe\b/i }, |
|
{ id: 'windows-vbs', label: '6202033.vbs', category: 'filesystem', severity: 'high', regex: /6202033\.vbs\b/i }, |
|
{ id: 'windows-ps1', label: '6202033.ps1', category: 'filesystem', severity: 'high', regex: /6202033\.ps1\b/i }, |
|
{ id: 'linux-ld-py', label: '/tmp/ld.py', category: 'filesystem', severity: 'high', regex: /\/tmp\/ld\.py\b/i }, |
|
{ id: 'mac-cache', label: '/Library/Caches/com.apple.act.mond', category: 'filesystem', severity: 'high', regex: /\/Library\/Caches\/com\.apple\.act\.mond\b/i }, |
|
{ id: 'npm-install', label: 'npm install or npm ci', category: 'execution', severity: 'high', regex: /\bnpm\s+(?:install|ci)\b/i }, |
|
{ id: 'npm-audit', label: 'npm audit', category: 'execution', severity: 'medium', regex: /\bnpm\s+audit\b/i }, |
|
{ id: 'npm-publish', label: 'npm publish', category: 'artifact', severity: 'medium', regex: /\bnpm\s+publish\b/i }, |
|
{ id: 'npm-pack', label: 'npm pack', category: 'artifact', severity: 'medium', regex: /\bnpm\s+pack\b/i }, |
|
{ id: 'docker-build', label: 'docker build', category: 'artifact', severity: 'medium', regex: /\bdocker\s+build\b/i }, |
|
{ id: 'docker-push', label: 'docker push', category: 'artifact', severity: 'medium', regex: /\bdocker\s+push\b/i }, |
|
{ id: 'dotnet-pack', label: 'dotnet pack', category: 'artifact', severity: 'medium', regex: /\bdotnet\s+pack\b/i }, |
|
{ id: 'nuget-push', label: 'nuget push', category: 'artifact', severity: 'medium', regex: /\bnuget\s+push\b/i }, |
|
{ id: 'mvn-deploy', label: 'mvn deploy', category: 'artifact', severity: 'medium', regex: /\bmvn\s+deploy\b/i }, |
|
{ id: 'gradle-publish', label: 'gradle publish', category: 'artifact', severity: 'medium', regex: /(?:\bgradle\b|\.\/gradlew)\s+publish\b/i }, |
|
{ id: 'postinstall', label: 'postinstall', category: 'execution', severity: 'high', regex: /\bpostinstall\b/i }, |
|
{ id: 'powershell', label: 'PowerShell or Invoke-WebRequest', category: 'execution', severity: 'high', regex: /\b(?:powershell|pwsh|Invoke-WebRequest|iwr)\b(?!:)/i }, |
|
{ id: 'python', label: 'python', category: 'execution', severity: 'high', regex: /\bpython(?:3)?\b(?![-\/.])/i }, |
|
{ id: 'curl-wget', label: 'curl or wget', category: 'execution', severity: 'medium', regex: /\b(?:curl|wget)\b/i }, |
|
]; |
|
|
|
// ─── incident-utils.js ────────────────────────────────────────────────────── |
|
function createLogger() { |
|
const hasColor = process.stdout.isTTY && process.env.NO_COLOR === undefined; |
|
const C = hasColor |
|
? { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', white: '\x1b[37m' } |
|
: Object.fromEntries(['reset', 'bold', 'dim', 'red', 'green', 'yellow', 'cyan', 'white'].map((k) => [k, ''])); |
|
|
|
const log = { |
|
info: (m) => console.log(`${C.cyan}ℹ${C.reset} ${m}`), |
|
ok: (m) => console.log(`${C.green}✔${C.reset} ${m}`), |
|
warn: (m) => console.log(`${C.yellow}⚠${C.reset} ${m}`), |
|
alert: (m) => console.log(`${C.red}${C.bold}✖${C.reset} ${m}`), |
|
dim: (m) => console.log(`${C.dim} ${m}${C.reset}`), |
|
step: (m) => console.log(`\n${C.bold}${C.white}▶ ${m}${C.reset}`), |
|
title: (m) => { |
|
const bar = '─'.repeat(m.length + 4); |
|
console.log(`\n${C.cyan}${C.bold}┌${bar}┐\n│ ${m} │\n└${bar}┘${C.reset}`); |
|
}, |
|
}; |
|
|
|
return { C, log }; |
|
} |
|
|
|
function readEnvFileIntoProcess() { |
|
const envFile = path.join(process.cwd(), '.env'); |
|
if (fs.existsSync(envFile)) { |
|
const lines = fs.readFileSync(envFile, 'utf8').split(/\r?\n/); |
|
for (const line of lines) { |
|
const trimmed = line.trim(); |
|
if (!trimmed || trimmed.startsWith('#')) continue; |
|
const eqIdx = trimmed.indexOf('='); |
|
if (eqIdx < 0) continue; |
|
const key = trimmed.slice(0, eqIdx).trim(); |
|
const value = trimmed |
|
.slice(eqIdx + 1) |
|
.trim() |
|
.replace(/^["']|["']$/g, ''); |
|
if (key && value && !process.env[key]) process.env[key] = value; |
|
} |
|
} |
|
} |
|
|
|
function normalizeConfigValue(value) { |
|
if (value === null || value === undefined) return ''; |
|
return String(value).trim(); |
|
} |
|
|
|
function isInteractiveTerminal() { |
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY); |
|
} |
|
|
|
function askQuestion(promptText, options = {}) { |
|
const { hidden = false } = options; |
|
|
|
return new Promise((resolve) => { |
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); |
|
|
|
if (hidden) { |
|
rl.stdoutMuted = true; |
|
rl._writeToOutput = function _writeToOutput(stringToWrite) { |
|
if (rl.stdoutMuted) { |
|
if (stringToWrite === '\n' || stringToWrite === '\r\n') { |
|
rl.output.write(stringToWrite); |
|
} else { |
|
rl.output.write('*'); |
|
} |
|
return; |
|
} |
|
|
|
rl.output.write(stringToWrite); |
|
}; |
|
|
|
process.stdout.write(promptText); |
|
rl.question('', (answer) => { |
|
rl.stdoutMuted = false; |
|
rl.close(); |
|
process.stdout.write('\n'); |
|
resolve(answer); |
|
}); |
|
return; |
|
} |
|
|
|
rl.question(promptText, (answer) => { |
|
rl.close(); |
|
resolve(answer); |
|
}); |
|
}); |
|
} |
|
|
|
async function resolveConfigValue(currentValue, options = {}) { |
|
const { interactive, promptText, required = true, allowBlank = false, hidden = false, missingMessage } = options; |
|
|
|
if (currentValue) { |
|
return currentValue; |
|
} |
|
|
|
if (!interactive) { |
|
if (required && !allowBlank) { |
|
throw new Error(missingMessage); |
|
} |
|
return ''; |
|
} |
|
|
|
const answer = normalizeConfigValue(await askQuestion(promptText, { hidden })); |
|
if (!answer && required && !allowBlank) { |
|
throw new Error(missingMessage); |
|
} |
|
|
|
return answer; |
|
} |
|
|
|
async function loadAdoConfig(options = {}) { |
|
const { org: orgInput = '', project: projectInput = '', pat: patInput = '', projectOptional = true } = options; |
|
|
|
readEnvFileIntoProcess(); |
|
|
|
let org = normalizeConfigValue(orgInput || process.env.ADO_ORG); |
|
let project = normalizeConfigValue(projectInput || process.env.ADO_PROJECT); |
|
let pat = normalizeConfigValue(patInput || process.env.ADO_PAT); |
|
|
|
const interactive = isInteractiveTerminal(); |
|
|
|
org = await resolveConfigValue(org, { |
|
interactive, |
|
promptText: 'Azure DevOps organization (ADO_ORG) [required]: ', |
|
required: true, |
|
missingMessage: 'Missing ADO_ORG. Pass --org, set ADO_ORG, or run in an interactive terminal.', |
|
}); |
|
|
|
if (projectOptional) { |
|
project = normalizeConfigValue(project); |
|
} else { |
|
project = await resolveConfigValue(project, { |
|
interactive, |
|
promptText: 'Azure DevOps project (ADO_PROJECT) [required]: ', |
|
required: true, |
|
missingMessage: 'Missing ADO_PROJECT. Pass --project, set ADO_PROJECT, or run in an interactive terminal.', |
|
}); |
|
} |
|
|
|
pat = await resolveConfigValue(pat, { |
|
interactive, |
|
promptText: 'Azure DevOps PAT (ADO_PAT) [required, input hidden]: ', |
|
required: true, |
|
hidden: true, |
|
missingMessage: 'Missing ADO_PAT. Pass --pat, set ADO_PAT, or run in an interactive terminal.', |
|
}); |
|
|
|
if (!org || !pat) { |
|
throw new Error('Missing ADO configuration. Organization and PAT are required.'); |
|
} |
|
|
|
return { |
|
org, |
|
project: project || null, |
|
pat, |
|
baseUrl: project ? `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis` : `https://dev.azure.com/${encodeURIComponent(org)}/_apis`, |
|
authHeader: 'Basic ' + Buffer.from(`:${pat}`).toString('base64'), |
|
}; |
|
} |
|
|
|
function httpRequest(reqUrl, authHeader, options = {}) { |
|
const { raw = false, method = 'GET', timeoutMs = 30_000, headers = {}, body = null } = options; |
|
|
|
return new Promise((resolve, reject) => { |
|
const parsed = new URL(reqUrl); |
|
const requestOptions = { |
|
hostname: parsed.hostname, |
|
path: parsed.pathname + parsed.search, |
|
method, |
|
headers: { |
|
Authorization: authHeader, |
|
Accept: raw ? 'text/plain' : 'application/json', |
|
...headers, |
|
}, |
|
}; |
|
|
|
const req = https.request(requestOptions, (res) => { |
|
let responseBody = ''; |
|
res.setEncoding('utf8'); |
|
res.on('data', (chunk) => { |
|
responseBody += chunk; |
|
}); |
|
res.on('end', () => { |
|
resolve({ |
|
statusCode: res.statusCode ?? 0, |
|
headers: res.headers, |
|
body: responseBody, |
|
}); |
|
}); |
|
}); |
|
|
|
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out'))); |
|
req.on('error', reject); |
|
|
|
if (body !== null) { |
|
req.write(body); |
|
} |
|
|
|
req.end(); |
|
}); |
|
} |
|
|
|
async function httpGetJson(reqUrl, authHeader, options = {}) { |
|
const response = await httpRequest(reqUrl, authHeader, options); |
|
const parsed = new URL(reqUrl); |
|
|
|
if (response.statusCode === 404) { |
|
return { body: null, headers: response.headers, statusCode: 404 }; |
|
} |
|
|
|
if (response.statusCode === 401 || response.statusCode === 203) { |
|
throw new Error('HTTP 401 — Invalid or expired PAT. Check ADO_PAT.'); |
|
} |
|
|
|
if (response.statusCode >= 400) { |
|
throw new Error(`HTTP ${response.statusCode} on ${parsed.pathname}`); |
|
} |
|
|
|
try { |
|
return { body: JSON.parse(response.body), headers: response.headers, statusCode: response.statusCode }; |
|
} catch { |
|
throw new Error(`Invalid JSON from ${parsed.pathname}`); |
|
} |
|
} |
|
|
|
async function httpGetText(reqUrl, authHeader, options = {}) { |
|
const response = await httpRequest(reqUrl, authHeader, { ...options, raw: true }); |
|
const parsed = new URL(reqUrl); |
|
|
|
if (response.statusCode === 404) { |
|
return { body: null, headers: response.headers, statusCode: 404 }; |
|
} |
|
|
|
if (response.statusCode === 401 || response.statusCode === 203) { |
|
throw new Error('HTTP 401 — Invalid or expired PAT. Check ADO_PAT.'); |
|
} |
|
|
|
if (response.statusCode >= 400) { |
|
throw new Error(`HTTP ${response.statusCode} on ${parsed.pathname}`); |
|
} |
|
|
|
return { body: response.body, headers: response.headers, statusCode: response.statusCode }; |
|
} |
|
|
|
class Semaphore { |
|
constructor(max) { |
|
this.max = max; |
|
this.count = 0; |
|
this.queue = []; |
|
} |
|
|
|
acquire() { |
|
return this.count < this.max ? (this.count++, Promise.resolve()) : new Promise((resolve) => this.queue.push(resolve)).then(() => this.count++); |
|
} |
|
|
|
release() { |
|
this.count--; |
|
const next = this.queue.shift(); |
|
if (next) next(); |
|
} |
|
} |
|
|
|
async function mapConcurrent(items, fn, limit = 5) { |
|
const sem = new Semaphore(limit); |
|
return Promise.all( |
|
items.map(async (item) => { |
|
await sem.acquire(); |
|
try { |
|
return await fn(item); |
|
} finally { |
|
sem.release(); |
|
} |
|
}), |
|
); |
|
} |
|
|
|
function makeProgress(total, C) { |
|
let done = 0; |
|
const width = 28; |
|
return { |
|
tick(label = '') { |
|
done++; |
|
const pct = total > 0 ? Math.round((done / total) * 100) : 100; |
|
const filled = total > 0 ? Math.round((done / total) * width) : width; |
|
const bar = `${'█'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`; |
|
const lbl = label.length > 35 ? label.slice(0, 32) + '...' : label.padEnd(35); |
|
process.stdout.write(`\r ${C.cyan}[${bar}]${C.reset} ${String(pct).padStart(3)}% ${C.dim}${lbl}${C.reset}`); |
|
if (done === total) process.stdout.write('\n'); |
|
}, |
|
}; |
|
} |
|
|
|
function normalizeDateRange(day) { |
|
const start = new Date(`${day}T00:00:00Z`); |
|
if (Number.isNaN(start.getTime())) { |
|
throw new TypeError(`Invalid date: ${day}`); |
|
} |
|
|
|
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); |
|
return { |
|
day, |
|
start: start.toISOString(), |
|
end: end.toISOString(), |
|
}; |
|
} |
|
|
|
function dedupeByKey(items, keyFn) { |
|
const seen = new Set(); |
|
return items.filter((item) => { |
|
const key = keyFn(item); |
|
if (seen.has(key)) return false; |
|
seen.add(key); |
|
return true; |
|
}); |
|
} |
|
|
|
function readTextIfExists(filePath) { |
|
try { |
|
const stats = fs.statSync(filePath); |
|
if (!stats.isFile()) return null; |
|
const body = fs.readFileSync(filePath, 'utf8'); |
|
return { |
|
path: filePath, |
|
body, |
|
size: stats.size, |
|
mtime: stats.mtime, |
|
}; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function resolveReportFormat(filePath, defaultFormat = 'csv') { |
|
const ext = path.extname(filePath).toLowerCase(); |
|
if (ext === '.csv') return 'csv'; |
|
if (ext === '.json') return 'json'; |
|
return defaultFormat; |
|
} |
|
|
|
function csvCell(value) { |
|
if (value === null || value === undefined) return ''; |
|
let text = value; |
|
if (typeof text === 'object') { |
|
text = JSON.stringify(text); |
|
} |
|
const stringValue = String(text); |
|
if (/[",\r\n]/.test(stringValue)) { |
|
return `"${stringValue.replace(/"/g, '""')}"`; |
|
} |
|
return stringValue; |
|
} |
|
|
|
function serializeCsv(rows, columns) { |
|
const header = columns.map((column) => csvCell(column)).join(','); |
|
const lines = rows.map((row) => columns.map((column) => csvCell(row[column])).join(',')); |
|
return [header, ...lines].join('\r\n'); |
|
} |
|
|
|
function writeReportFile(filePath, jsonData, csvRows, csvColumns, defaultFormat = 'csv') { |
|
const format = resolveReportFormat(filePath, defaultFormat); |
|
if (format === 'json') { |
|
fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2), 'utf8'); |
|
} else { |
|
fs.writeFileSync(filePath, serializeCsv(csvRows, csvColumns), 'utf8'); |
|
} |
|
return format; |
|
} |
|
|
|
function walkFiles(roots, options = {}) { |
|
const maxDepth = options.maxDepth ?? 4; |
|
const filter = options.filter ?? (() => true); |
|
const ignoreDirs = new Set(options.ignoreDirs ?? ['.git', 'node_modules', 'dist', 'build', '.cache', 'coverage']); |
|
const result = []; |
|
const queue = []; |
|
const visited = new Set(); |
|
|
|
for (const root of roots.filter(Boolean)) { |
|
queue.push({ dir: path.resolve(root), depth: 0 }); |
|
} |
|
|
|
while (queue.length > 0) { |
|
const current = queue.shift(); |
|
if (!current || visited.has(current.dir)) continue; |
|
visited.add(current.dir); |
|
|
|
let entries; |
|
try { |
|
entries = fs.readdirSync(current.dir, { withFileTypes: true }); |
|
} catch { |
|
continue; |
|
} |
|
|
|
for (const entry of entries) { |
|
const fullPath = path.join(current.dir, entry.name); |
|
if (entry.isSymbolicLink()) continue; |
|
|
|
if (entry.isDirectory()) { |
|
if (current.depth < maxDepth && !ignoreDirs.has(entry.name)) { |
|
queue.push({ dir: fullPath, depth: current.depth + 1 }); |
|
} |
|
continue; |
|
} |
|
|
|
if (entry.isFile() && filter(fullPath, entry, current.depth)) { |
|
result.push(fullPath); |
|
} |
|
} |
|
} |
|
|
|
return dedupeByKey(result, (item) => item); |
|
} |
|
|
|
function scanTextForPatterns(text, patterns, contextSize = 80) { |
|
const hits = []; |
|
|
|
for (const pattern of patterns) { |
|
const regex = new RegExp(pattern.regex.source, pattern.regex.flags.replace('g', '')); |
|
const match = regex.exec(text); |
|
if (!match) continue; |
|
|
|
const index = match.index ?? text.indexOf(match[0]); |
|
const start = Math.max(0, index - contextSize); |
|
const end = Math.min(text.length, index + match[0].length + contextSize); |
|
const snippet = text.slice(start, end).replace(/\s+/g, ' ').trim(); |
|
|
|
hits.push({ |
|
id: pattern.id, |
|
label: pattern.label, |
|
category: pattern.category, |
|
severity: pattern.severity, |
|
match: match[0], |
|
snippet, |
|
}); |
|
} |
|
|
|
return dedupeByKey(hits, (item) => `${item.id}|${item.match}|${item.snippet}`); |
|
} |
|
|
|
// ─── workstation-audit.js ─────────────────────────────────────────────────── |
|
const REPORT_FILE = 'workstation-audit-report.csv'; |
|
const CSV_COLUMNS = [ |
|
'recordType', |
|
'auditDate', |
|
'host', |
|
'platform', |
|
'root', |
|
'targetedFiles', |
|
'scannedFiles', |
|
'filesWithHits', |
|
'totalHits', |
|
'criticalHits', |
|
'categories', |
|
'path', |
|
'size', |
|
'mtime', |
|
'id', |
|
'label', |
|
'category', |
|
'severity', |
|
'match', |
|
'snippet', |
|
]; |
|
|
|
function parseArgs(argv) { |
|
const args = argv.slice(2); |
|
const opts = { output: REPORT_FILE, root: null, help: false, maxDepth: 4 }; |
|
|
|
for (let i = 0; i < args.length; i++) { |
|
switch (args[i]) { |
|
case '--output': |
|
case '-o': |
|
opts.output = args[++i]; |
|
break; |
|
case '--root': |
|
case '-r': |
|
opts.root = args[++i]; |
|
break; |
|
case '--max-depth': |
|
case '--depth': |
|
opts.maxDepth = Number(args[++i]); |
|
break; |
|
case '--help': |
|
case '-h': |
|
opts.help = true; |
|
break; |
|
} |
|
} |
|
|
|
return opts; |
|
} |
|
|
|
function joinIfPresent(...parts) { |
|
if (parts.some((part) => !part)) return null; |
|
return path.join(...parts); |
|
} |
|
|
|
function getDefaultTargets() { |
|
const home = os.homedir(); |
|
const tempDir = process.env.TEMP || process.env.TMP || os.tmpdir(); |
|
const appData = process.env.APPDATA; |
|
const localAppData = process.env.LOCALAPPDATA; |
|
const programData = process.env.PROGRAMDATA; |
|
|
|
const files = [ |
|
joinIfPresent(home, '.bash_history'), |
|
joinIfPresent(home, '.zsh_history'), |
|
joinIfPresent(home, '.local', 'share', 'fish', 'fish_history'), |
|
joinIfPresent(appData, 'Microsoft', 'Windows', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'), |
|
joinIfPresent(appData, 'Microsoft', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'), |
|
joinIfPresent(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'), |
|
joinIfPresent(home, 'AppData', 'Roaming', 'Microsoft', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'), |
|
joinIfPresent(tempDir, '6202033.vbs'), |
|
joinIfPresent(tempDir, '6202033.ps1'), |
|
joinIfPresent(programData, 'wt.exe'), |
|
'/tmp/ld.py', |
|
'/Library/Caches/com.apple.act.mond', |
|
].filter(Boolean); |
|
|
|
const dirs = [ |
|
joinIfPresent(home, '.npm', '_logs'), |
|
joinIfPresent(appData, 'npm-cache', '_logs'), |
|
joinIfPresent(localAppData, 'npm-cache', '_logs'), |
|
joinIfPresent(home, 'Library', 'Logs', 'npm'), |
|
].filter(Boolean); |
|
|
|
return { files, dirs }; |
|
} |
|
|
|
function isTextLikeEvidence(filePath) { |
|
const name = path.basename(filePath).toLowerCase(); |
|
// Exclude lock files by name — large, mostly dependency metadata, high noise |
|
if ( |
|
name === 'package-lock.json' || |
|
name === 'yarn.lock' || |
|
name === 'pnpm-lock.yaml' || |
|
name === 'pnpm-lock.yml' || |
|
name === 'composer.lock' || |
|
name === 'gemfile.lock' || |
|
name === 'poetry.lock' || |
|
name === 'cargo.lock' |
|
) return false; |
|
return ( |
|
name.endsWith('.log') || |
|
name.endsWith('.txt') || |
|
name.endsWith('.json') || |
|
name.endsWith('.yaml') || |
|
name.endsWith('.yml') || |
|
name.endsWith('.history') || |
|
name.endsWith('.ps1') || |
|
name.endsWith('.sh') || |
|
name.endsWith('.zsh') || |
|
name.endsWith('.bash') || |
|
name.endsWith('.py') || |
|
name === 'fish_history' || |
|
name === 'consolehost_history.txt' || |
|
name.startsWith('npm-debug') || |
|
name.startsWith('pnpm-debug') || |
|
name.startsWith('yarn-error') |
|
); |
|
} |
|
|
|
function scanFile(filePath) { |
|
const file = readTextIfExists(filePath); |
|
if (!file) return []; |
|
|
|
const hits = scanTextForPatterns(file.body, INCIDENT_PATTERNS); |
|
return hits.map((hit) => ({ |
|
path: file.path, |
|
size: file.size, |
|
mtime: file.mtime.toISOString(), |
|
...hit, |
|
})); |
|
} |
|
|
|
function groupByFile(hits) { |
|
const byFile = new Map(); |
|
for (const hit of hits) { |
|
const bucket = byFile.get(hit.path) ?? []; |
|
bucket.push(hit); |
|
byFile.set(hit.path, bucket); |
|
} |
|
return [...byFile.entries()].map(([filePath, fileHits]) => ({ |
|
path: filePath, |
|
hits: fileHits, |
|
})); |
|
} |
|
|
|
function showHelp() { |
|
const { C } = createLogger(); |
|
console.log(` |
|
${C.bold}${C.cyan}workstation-audit${C.reset} |
|
Local evidence collector for developer workstations |
|
|
|
USAGE |
|
node workstation-audit.js [options] |
|
|
|
OPTIONS |
|
--root, -r <path> Extra folder to scan recursively for text evidence (exported host/evidence dump) |
|
--output, -o <file> Output file (default: ${REPORT_FILE}) |
|
--max-depth <n> Max recursion depth for --root (default: 4) |
|
--help, -h Show this help |
|
|
|
DEFAULT TARGETS |
|
- shell history files |
|
- npm debug logs |
|
- known temporary files from the incident IOCs |
|
|
|
RUNBOOK |
|
docs/workstation-audit.md |
|
|
|
FORMAT |
|
Output format follows the file extension: .csv by default, .json if you pass a .json file name. |
|
`); |
|
} |
|
|
|
function getSeverityColor(severity, C) { |
|
if (severity === 'critical') return C.red; |
|
if (severity === 'high') return C.yellow; |
|
return C.cyan; |
|
} |
|
|
|
function scanKnownFiles(files, C) { |
|
const hits = []; |
|
const progress = makeProgress(files.length, C); |
|
|
|
for (const filePath of files) { |
|
progress.tick(path.basename(filePath)); |
|
hits.push(...scanFile(filePath)); |
|
} |
|
|
|
return hits; |
|
} |
|
|
|
function scanEvidenceDirectories(dirs, opts, C) { |
|
const walkedFiles = walkFiles(dirs, { |
|
maxDepth: Number.isFinite(opts.maxDepth) ? opts.maxDepth : 4, |
|
filter: isTextLikeEvidence, |
|
}); |
|
|
|
const hits = []; |
|
const progress = makeProgress(walkedFiles.length, C); |
|
|
|
for (const filePath of walkedFiles) { |
|
progress.tick(path.basename(filePath)); |
|
hits.push(...scanFile(filePath)); |
|
} |
|
|
|
return { walkedFiles, hits }; |
|
} |
|
|
|
function buildWorkstationAudit(opts) { |
|
const { C, log } = createLogger(); |
|
log.title('WORKSTATION AUDIT — Local evidence collection'); |
|
|
|
const targets = buildEvidenceTargets(opts); |
|
log.step('Checking known files...'); |
|
const directHits = scanKnownFiles(targets.files, C); |
|
|
|
log.step('Walking evidence directories...'); |
|
const walked = scanEvidenceDirectories(targets.dirs, opts, C); |
|
|
|
const allHits = dedupeByKey([...directHits, ...walked.hits], (hit) => `${hit.path}|${hit.id}|${hit.match}|${hit.snippet}`); |
|
const filesWithHits = groupByFile(allHits); |
|
const criticalHits = allHits.filter((hit) => hit.severity === 'critical'); |
|
const scannedFiles = dedupeByKey([...targets.files, ...walked.walkedFiles], (filePath) => filePath); |
|
|
|
const summary = { |
|
targetedFiles: targets.files.length, |
|
scannedFiles: scannedFiles.length, |
|
filesWithHits: filesWithHits.length, |
|
totalHits: allHits.length, |
|
criticalHits: criticalHits.length, |
|
categories: allHits.reduce((acc, hit) => { |
|
acc[hit.category] = (acc[hit.category] ?? 0) + 1; |
|
return acc; |
|
}, {}), |
|
}; |
|
|
|
return { |
|
C, |
|
log, |
|
summary, |
|
filesWithHits, |
|
criticalHits, |
|
report: { |
|
auditDate: new Date().toISOString(), |
|
scope: { |
|
root: opts.root ? path.resolve(opts.root) : null, |
|
host: os.hostname(), |
|
platform: process.platform, |
|
}, |
|
summary, |
|
findings: allHits, |
|
filesWithHits, |
|
}, |
|
}; |
|
} |
|
|
|
function buildWorkstationCsvRows(result) { |
|
const { summary, report, filesWithHits } = result; |
|
const rows = [ |
|
{ |
|
recordType: 'summary', |
|
auditDate: report.auditDate, |
|
host: report.scope.host, |
|
platform: report.scope.platform, |
|
root: report.scope.root, |
|
targetedFiles: summary.targetedFiles, |
|
scannedFiles: summary.scannedFiles, |
|
filesWithHits: summary.filesWithHits, |
|
totalHits: summary.totalHits, |
|
criticalHits: summary.criticalHits, |
|
categories: JSON.stringify(summary.categories), |
|
}, |
|
]; |
|
|
|
for (const file of filesWithHits) { |
|
for (const hit of file.hits) { |
|
rows.push({ |
|
recordType: 'finding', |
|
auditDate: report.auditDate, |
|
host: report.scope.host, |
|
platform: report.scope.platform, |
|
root: report.scope.root, |
|
path: file.path, |
|
size: hit.size, |
|
mtime: hit.mtime, |
|
id: hit.id, |
|
label: hit.label, |
|
category: hit.category, |
|
severity: hit.severity, |
|
match: hit.match, |
|
snippet: hit.snippet, |
|
}); |
|
} |
|
} |
|
|
|
return rows; |
|
} |
|
|
|
function printWorkstationReport(result) { |
|
const { C, log, summary, filesWithHits } = result; |
|
|
|
log.step('Results:'); |
|
|
|
if (summary.criticalHits === 0) { |
|
// No critical IOCs — machine is clear for the purposes of this investigation. |
|
// High/medium hits (npm install, curl, etc.) are corroborating evidence only; |
|
// they are saved in the report but have no meaning without a critical IOC. |
|
log.ok('CLEAR — no critical IOCs detected (axios@1.14.1, plain-crypto-js@4.2.1, sfrclak.com, ld.py...).'); |
|
console.log(`\n 📄 ${result.reportPath} (${summary.scannedFiles} files scanned)\n`); |
|
return; |
|
} |
|
|
|
// ── Critical IOCs found — action required ────────────────────────────────── |
|
const criticalFiles = filesWithHits.filter((f) => f.hits.some((h) => h.severity === 'critical')); |
|
|
|
console.log(`\n${C.red}${C.bold}━━━ CRITICAL IOCs DETECTED ━━━${C.reset}`); |
|
for (const file of criticalFiles) { |
|
console.log(`\n ${C.bold}${file.path}${C.reset}`); |
|
for (const hit of file.hits.filter((h) => h.severity === 'critical')) { |
|
console.log(` ${C.red}${hit.label}${C.reset} ${C.dim}(${hit.category})${C.reset}`); |
|
console.log(` ${C.dim}${hit.snippet}${C.reset}`); |
|
} |
|
} |
|
|
|
console.log(`\n${C.red}${C.bold}━━━ ACTION REQUIRED ━━━${C.reset}`); |
|
console.log(` ${C.red}1.${C.reset} Isolate the workstation — treat as compromised`); |
|
console.log(` ${C.red}2.${C.reset} Rotate all secrets exposed on this host`); |
|
console.log(` ${C.red}3.${C.reset} Contact the IR team immediately`); |
|
console.log(`\n 📄 ${result.reportPath} (full execution trace inside)\n`); |
|
} |
|
|
|
function buildEvidenceTargets(opts) { |
|
const defaults = getDefaultTargets(); |
|
const fileTargets = [...defaults.files]; |
|
const dirTargets = [...defaults.dirs]; |
|
|
|
if (opts.root) { |
|
const resolvedRoot = path.resolve(opts.root); |
|
if (fs.existsSync(resolvedRoot)) { |
|
const stats = fs.statSync(resolvedRoot); |
|
if (stats.isDirectory()) { |
|
dirTargets.push(resolvedRoot); |
|
} else if (stats.isFile()) { |
|
fileTargets.push(resolvedRoot); |
|
} |
|
} |
|
} |
|
|
|
return { |
|
files: fileTargets, |
|
dirs: dirTargets, |
|
}; |
|
} |
|
|
|
async function main() { |
|
const opts = parseArgs(process.argv); |
|
|
|
if (opts.help) { |
|
showHelp(); |
|
process.exit(0); |
|
} |
|
|
|
const result = buildWorkstationAudit(opts); |
|
result.reportPath = opts.output; |
|
writeReportFile(opts.output, result.report, buildWorkstationCsvRows(result), CSV_COLUMNS); |
|
printWorkstationReport(result); |
|
} |
|
|
|
main().catch((err) => { |
|
const { log } = createLogger(); |
|
log.alert(`Fatal: ${err.message}`); |
|
process.exit(1); |
|
}); |