@neurolint/cli
Version:
NeuroLint CLI - Deterministic code fixing for TypeScript, JavaScript, React, and Next.js with 8-layer architecture including Security Forensics, Next.js 16, React Compiler, and Turbopack support
582 lines (502 loc) • 19.7 kB
JavaScript
/**
* Copyright (c) 2025 NeuroLint
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Layer 8: Security Forensics - Timeline Reconstructor
*
* Analyzes git history to reconstruct when suspicious files were modified,
* identify potential attack timelines, and correlate changes across the codebase.
*
* IMPORTANT: Layer 8 is READ-ONLY by default. It detects but does not transform
* unless explicitly requested (quarantine mode). This follows the NeuroLint
* principle of "never break code".
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { SEVERITY_LEVELS, IOC_CATEGORIES } = require('../constants');
class TimelineReconstructor {
constructor(options = {}) {
this.verbose = options.verbose || false;
this.maxCommits = options.maxCommits || 500;
this.lookbackDays = options.lookbackDays || 90;
this.suspiciousPatterns = this.loadSuspiciousPatterns();
}
loadSuspiciousPatterns() {
return [
{ pattern: /eval\s*\(/gi, name: 'eval() call', severity: SEVERITY_LEVELS.CRITICAL },
{ pattern: /new\s+Function\s*\(/gi, name: 'Function constructor', severity: SEVERITY_LEVELS.HIGH },
{ pattern: /child_process/gi, name: 'child_process import', severity: SEVERITY_LEVELS.HIGH },
{ pattern: /exec\s*\(/gi, name: 'exec() call', severity: SEVERITY_LEVELS.CRITICAL },
{ pattern: /spawn\s*\(/gi, name: 'spawn() call', severity: SEVERITY_LEVELS.HIGH },
{ pattern: /atob\s*\(/gi, name: 'atob() decoding', severity: SEVERITY_LEVELS.MEDIUM },
{ pattern: /Buffer\.from\s*\([^)]+,\s*['"]base64['"]/gi, name: 'Base64 Buffer', severity: SEVERITY_LEVELS.MEDIUM },
{ pattern: /['"]use server['"]/g, name: 'Server action directive', severity: SEVERITY_LEVELS.MEDIUM },
{ pattern: /process\.env/gi, name: 'Environment access', severity: SEVERITY_LEVELS.LOW },
{ pattern: /crypto\.(createCipher|createDecipher)/gi, name: 'Crypto operations', severity: SEVERITY_LEVELS.LOW },
{ pattern: /webhook\.site|pastebin\.com|requestbin/gi, name: 'Suspicious domain', severity: SEVERITY_LEVELS.HIGH },
{ pattern: /https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/gi, name: 'IP address URL', severity: SEVERITY_LEVELS.HIGH }
];
}
isGitRepository(targetPath) {
try {
execSync('git rev-parse --is-inside-work-tree', {
cwd: targetPath,
stdio: 'pipe'
});
return true;
} catch {
return false;
}
}
reconstructTimeline(targetPath, options = {}) {
if (!this.isGitRepository(targetPath)) {
return {
success: false,
error: 'Not a git repository',
timeline: [],
findings: []
};
}
const timeline = [];
const findings = [];
try {
const commits = this.getRecentCommits(targetPath);
for (const commit of commits) {
const commitDetails = this.analyzeCommit(targetPath, commit);
if (commitDetails.suspiciousChanges.length > 0 ||
commitDetails.newFiles.length > 0 ||
options.includeAll) {
timeline.push(commitDetails);
}
findings.push(...commitDetails.findings);
}
const sensitiveFiles = this.checkSensitiveFiles(targetPath);
findings.push(...sensitiveFiles);
const unusualAuthors = this.detectUnusualAuthors(commits);
findings.push(...unusualAuthors);
const suspiciousTiming = this.detectSuspiciousTiming(commits);
findings.push(...suspiciousTiming);
if (options.detectForcesPush) {
const forcePushes = this.detectForcePushes(targetPath);
findings.push(...forcePushes);
}
} catch (error) {
if (this.verbose) {
console.error(`[Layer 8] Timeline reconstruction error: ${error.message}`);
}
return {
success: false,
error: error.message,
timeline: [],
findings: []
};
}
return {
success: true,
timeline: timeline.sort((a, b) => new Date(b.date) - new Date(a.date)),
findings,
stats: {
totalCommits: timeline.length,
totalFindings: findings.length,
dateRange: {
start: timeline.length > 0 ? timeline[timeline.length - 1].date : null,
end: timeline.length > 0 ? timeline[0].date : null
}
}
};
}
getRecentCommits(targetPath) {
const since = new Date();
since.setDate(since.getDate() - this.lookbackDays);
const sinceStr = since.toISOString().split('T')[0];
try {
const output = execSync(
`git log --since="${sinceStr}" --format="%H|%an|%ae|%ad|%s" --date=iso -n ${this.maxCommits}`,
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
return output.trim().split('\n')
.filter(line => line.length > 0)
.map(line => {
const [hash, author, email, date, ...subject] = line.split('|');
return {
hash,
author,
email,
date,
subject: subject.join('|')
};
});
} catch (error) {
return [];
}
}
analyzeCommit(targetPath, commit) {
const result = {
hash: commit.hash,
shortHash: commit.hash.substring(0, 8),
author: commit.author,
email: commit.email,
date: commit.date,
subject: commit.subject,
newFiles: [],
modifiedFiles: [],
deletedFiles: [],
suspiciousChanges: [],
findings: []
};
try {
const diffStat = execSync(
`git show --stat --format="" ${commit.hash}`,
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
const filesChanged = diffStat.trim().split('\n')
.filter(line => line.includes('|'))
.map(line => {
const match = line.match(/^\s*([^|]+)\s*\|/);
return match ? match[1].trim() : null;
})
.filter(f => f);
const nameStatus = execSync(
`git show --name-status --format="" ${commit.hash}`,
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
nameStatus.trim().split('\n').forEach(line => {
const [status, ...fileParts] = line.split('\t');
const file = fileParts.join('\t');
if (status === 'A') result.newFiles.push(file);
else if (status === 'M') result.modifiedFiles.push(file);
else if (status === 'D') result.deletedFiles.push(file);
});
for (const file of [...result.newFiles, ...result.modifiedFiles]) {
const suspicious = this.checkFileSuspicious(targetPath, commit.hash, file);
if (suspicious.length > 0) {
result.suspiciousChanges.push({
file,
patterns: suspicious
});
for (const pattern of suspicious) {
result.findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-001',
signatureName: `Suspicious Pattern: ${pattern.name}`,
severity: pattern.severity,
category: IOC_CATEGORIES.PERSISTENCE,
description: `Pattern "${pattern.name}" introduced in commit ${result.shortHash}`,
file,
remediation: `Review commit ${result.shortHash} and verify this change was intentional`,
references: ['git-forensics'],
metadata: {
commitHash: commit.hash,
author: commit.author,
date: commit.date
}
}));
}
}
}
const sensitiveExtensions = ['.env', '.pem', '.key', '.p12', '.pfx'];
for (const file of result.newFiles) {
const ext = path.extname(file).toLowerCase();
const basename = path.basename(file).toLowerCase();
if (sensitiveExtensions.includes(ext) ||
basename.includes('secret') ||
basename.includes('password') ||
basename.includes('credential')) {
result.findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-002',
signatureName: 'Sensitive File Added',
severity: SEVERITY_LEVELS.HIGH,
category: IOC_CATEGORIES.DATA_EXFILTRATION,
description: `Potentially sensitive file "${file}" was added in commit ${result.shortHash}`,
file,
remediation: 'Verify this file should be in version control',
metadata: {
commitHash: commit.hash,
author: commit.author,
date: commit.date
}
}));
}
}
const configFiles = ['next.config.js', 'next.config.mjs', 'webpack.config.js',
'.babelrc', 'babel.config.js', 'package.json'];
for (const file of result.modifiedFiles) {
const basename = path.basename(file);
if (configFiles.includes(basename)) {
result.findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-003',
signatureName: 'Build Configuration Modified',
severity: SEVERITY_LEVELS.MEDIUM,
category: IOC_CATEGORIES.SUPPLY_CHAIN,
description: `Build configuration "${file}" was modified in commit ${result.shortHash}`,
file,
remediation: 'Review changes to build configuration files',
metadata: {
commitHash: commit.hash,
author: commit.author,
date: commit.date
}
}));
}
}
} catch (error) {
if (this.verbose) {
console.error(`[Layer 8] Error analyzing commit ${commit.hash}: ${error.message}`);
}
}
return result;
}
checkFileSuspicious(targetPath, commitHash, file) {
const matches = [];
try {
const diff = execSync(
`git show ${commitHash} -- "${file}"`,
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024 }
);
const addedLines = diff.split('\n')
.filter(line => line.startsWith('+') && !line.startsWith('+++'))
.map(line => line.substring(1));
const content = addedLines.join('\n');
for (const { pattern, name, severity } of this.suspiciousPatterns) {
if (pattern.test(content)) {
matches.push({ name, severity });
pattern.lastIndex = 0;
}
}
} catch (error) {
}
return matches;
}
checkSensitiveFiles(targetPath) {
const findings = [];
const sensitivePatterns = [
{ pattern: /\.env(?:\.local|\.production|\.development)?$/i, name: 'Environment file' },
{ pattern: /id_rsa|id_ed25519|id_ecdsa/i, name: 'SSH key' },
{ pattern: /\.pem$|\.key$|\.p12$|\.pfx$/i, name: 'Certificate/Key file' },
{ pattern: /credentials|secrets|passwords/i, name: 'Credentials file' },
{ pattern: /\.npmrc$|\.yarnrc$/i, name: 'Package manager config' }
];
try {
const trackedFiles = execSync(
'git ls-files',
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim().split('\n');
for (const file of trackedFiles) {
for (const { pattern, name } of sensitivePatterns) {
if (pattern.test(file)) {
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-004',
signatureName: `${name} in Repository`,
severity: SEVERITY_LEVELS.HIGH,
category: IOC_CATEGORIES.DATA_EXFILTRATION,
description: `Sensitive file "${file}" (${name}) is tracked in git`,
file,
remediation: 'Remove sensitive files from git history using git-filter-branch or BFG'
}));
break;
}
}
}
} catch (error) {
}
return findings;
}
detectUnusualAuthors(commits) {
const findings = [];
const authorCounts = {};
for (const commit of commits) {
const key = `${commit.author} <${commit.email}>`;
authorCounts[key] = (authorCounts[key] || 0) + 1;
}
const totalCommits = commits.length;
for (const [author, count] of Object.entries(authorCounts)) {
const percentage = (count / totalCommits) * 100;
if (count === 1 && totalCommits > 10) {
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-005',
signatureName: 'Single-commit Author',
severity: SEVERITY_LEVELS.LOW,
category: IOC_CATEGORIES.PERSISTENCE,
description: `Author "${author}" has only 1 commit in the analyzed period`,
remediation: 'Verify this author is a legitimate contributor',
metadata: { author, commitCount: count }
}));
}
const emailMatch = author.match(/<([^>]+)>/);
if (emailMatch) {
const email = emailMatch[1];
const suspiciousDomains = ['temp-mail', 'guerrilla', 'mailinator', '10minute'];
for (const domain of suspiciousDomains) {
if (email.includes(domain)) {
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-006',
signatureName: 'Temporary Email Author',
severity: SEVERITY_LEVELS.MEDIUM,
category: IOC_CATEGORIES.PERSISTENCE,
description: `Author "${author}" uses a temporary email service`,
remediation: 'Investigate commits from this author'
}));
}
}
}
}
return findings;
}
detectSuspiciousTiming(commits) {
const findings = [];
const hourCounts = {};
const weekendCommits = [];
for (const commit of commits) {
const date = new Date(commit.date);
const hour = date.getHours();
const dayOfWeek = date.getDay();
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
if (dayOfWeek === 0 || dayOfWeek === 6) {
weekendCommits.push(commit);
}
if (hour >= 2 && hour <= 5) {
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-007',
signatureName: 'Late Night Commit',
severity: SEVERITY_LEVELS.LOW,
category: IOC_CATEGORIES.PERSISTENCE,
description: `Commit ${commit.hash.substring(0, 8)} made at unusual hour (${hour}:00)`,
remediation: 'Review commits made at unusual hours',
metadata: {
commitHash: commit.hash,
author: commit.author,
hour
}
}));
}
}
const sortedCommits = [...commits].sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
for (let i = 1; i < sortedCommits.length; i++) {
const prev = new Date(sortedCommits[i - 1].date);
const curr = new Date(sortedCommits[i].date);
const diffMinutes = (curr - prev) / (1000 * 60);
if (diffMinutes > 0 && diffMinutes < 1) {
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-008',
signatureName: 'Rapid-fire Commits',
severity: SEVERITY_LEVELS.LOW,
category: IOC_CATEGORIES.PERSISTENCE,
description: `Commits ${sortedCommits[i-1].hash.substring(0, 8)} and ${sortedCommits[i].hash.substring(0, 8)} were made within 1 minute`,
remediation: 'Review rapid commit sequences for automation or scripted changes'
}));
}
}
return findings;
}
detectForcePushes(targetPath) {
const findings = [];
try {
const reflog = execSync(
'git reflog --format="%H|%gs|%gd|%ci" -n 100',
{ cwd: targetPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
const lines = reflog.trim().split('\n').filter(l => l);
for (const line of lines) {
if (line.includes('forced-update') || line.includes('reset:')) {
const [hash, action, ref, date] = line.split('|');
findings.push(this.createFinding({
signatureId: 'NEUROLINT-TL-009',
signatureName: 'Force Push or Reset Detected',
severity: SEVERITY_LEVELS.MEDIUM,
category: IOC_CATEGORIES.PERSISTENCE,
description: `History modification detected: ${action}`,
remediation: 'Investigate why history was modified',
metadata: {
hash,
action,
ref,
date
}
}));
}
}
} catch (error) {
}
return findings;
}
createFinding(data) {
return {
id: `finding-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
line: 1,
column: 1,
confidence: data.confidence || 0.75,
timestamp: new Date().toISOString(),
...data
};
}
generateReport(result) {
const report = {
success: result.success,
generatedAt: new Date().toISOString(),
stats: result.stats,
timeline: result.timeline.map(commit => ({
hash: commit.shortHash,
author: commit.author,
date: commit.date,
subject: commit.subject,
filesChanged: {
added: commit.newFiles.length,
modified: commit.modifiedFiles.length,
deleted: commit.deletedFiles.length
},
suspiciousChanges: commit.suspiciousChanges.length
})),
findings: result.findings,
riskAssessment: this.assessRisk(result)
};
return report;
}
assessRisk(result) {
let score = 0;
for (const finding of result.findings) {
switch (finding.severity) {
case SEVERITY_LEVELS.CRITICAL: score += 100; break;
case SEVERITY_LEVELS.HIGH: score += 50; break;
case SEVERITY_LEVELS.MEDIUM: score += 20; break;
case SEVERITY_LEVELS.LOW: score += 5; break;
}
}
let level;
if (score >= 200) level = 'critical';
else if (score >= 100) level = 'high';
else if (score >= 50) level = 'medium';
else if (score > 0) level = 'low';
else level = 'clean';
return {
score,
level,
summary: this.generateRiskSummary(level, result.findings.length)
};
}
generateRiskSummary(level, findingsCount) {
const summaries = {
critical: `CRITICAL: ${findingsCount} security-relevant changes detected in git history that require immediate investigation.`,
high: `HIGH RISK: ${findingsCount} suspicious changes found in git history. Review recommended.`,
medium: `MEDIUM RISK: ${findingsCount} potentially concerning changes detected. Monitor and review.`,
low: `LOW RISK: ${findingsCount} minor observations in git history. No immediate action required.`,
clean: 'No suspicious changes detected in git history within the analyzed period.'
};
return summaries[level] || summaries.clean;
}
}
module.exports = TimelineReconstructor;