Skip to content

Instantly share code, notes, and snippets.

@Fazzani
Last active April 2, 2026 10:02
Show Gist options
  • Select an option

  • Save Fazzani/37ab83793085de3a96d27a8d84080d32 to your computer and use it in GitHub Desktop.

Select an option

Save Fazzani/37ab83793085de3a96d27a8d84080d32 to your computer and use it in GitHub Desktop.
#!/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);
});

Workstation Audit — Guide développeur

Script de collecte d'indices locaux pour auditer un poste développeur suite à l'incident axios (mars 2026).

Prérequis : Node.js ≥ 14. Aucune installation supplémentaire.


Commande à lancer

Windows (PowerShell)

irm https://gist.githubusercontent.com/REMPLACER_PAR_URL_REELLE/raw/workstation-audit-standalone.js | node

macOS / Linux

curl -fsSL https://gist.githubusercontent.com/REMPLACER_PAR_URL_REELLE/raw/workstation-audit-standalone.js | node

Note : Remplace l'URL par la vraie URL stable du Gist (voir ci-dessous — elle est affichée automatiquement après npm run build:standalone).

Envoie au CERT le fichier workstation-audit-report.csv généré.


Options utiles

--output <file>       Nom du rapport (défaut : workstation-audit-report.csv)
--root <dossier>      Scanner un dossier d'export ou dump d'evidence hors du poste live
--max-depth <n>       Profondeur de récursion pour --root (défaut : 4)
--help                Afficher l'aide

Exemples :

# Rapport nommé avec l'identifiant du poste
irm <URL> | node - --output audit-alice-2026-04-02.csv

# Scanner un export de machine
irm <URL> | node - --root C:\evidence\host-export --output audit-host.csv

Ce que le script inspecte par défaut

  • Historique shell (bash, zsh, fish, PowerShell)
  • Logs npm debug (npm-cache\_logs)
  • Fichiers temporaires IOC connus (6202033.vbs, 6202033.ps1, wt.exe, ld.py)

Mettre à jour le Gist (mainteneurs)

# Depuis le repo après modification des sources :
$env:GIST_ID    = "TON_GIST_ID"
$env:GIST_TOKEN = "TON_TOKEN_GIST"
npm run build:standalone
# → affiche la nouvelle URL stable à partager

Le token GitHub n'a besoin que du scope gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment