Initial OfficeCom Sentinel client and deployment assets
This commit is contained in:
477
src/AttackTracerNinjaCli/AttackScanner.cs
Normal file
477
src/AttackTracerNinjaCli/AttackScanner.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class AttackScanner
|
||||
{
|
||||
private static readonly string[] DefaultFtpRoots =
|
||||
[
|
||||
@"C:\inetpub\logs\LogFiles",
|
||||
@"D:\inetpub\logs\LogFiles"
|
||||
];
|
||||
|
||||
private static readonly string[] DefaultFileZillaRoots =
|
||||
[
|
||||
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
||||
@"D:\Program Files (x86)\FileZilla Server\Logs"
|
||||
];
|
||||
|
||||
public ScanResult Run(ScanOptions options)
|
||||
{
|
||||
DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow;
|
||||
ScannerConfiguration configuration = LoadConfiguration(options);
|
||||
var attacks = new List<AttackEvent>();
|
||||
var errors = new List<string>();
|
||||
DateTimeOffset since = DateTimeOffset.Now.AddDays(-options.LookbackDays);
|
||||
|
||||
ScanWindowsLogons(attacks, errors, since);
|
||||
ScanSqlLogons(attacks, errors, since);
|
||||
ScanExchangeLogons(attacks, errors, since);
|
||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||
|
||||
if (configuration.ExcludedIps.Count > 0)
|
||||
{
|
||||
attacks = attacks
|
||||
.Where(attack => !configuration.ExcludedIps.Contains(attack.SourceIp, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
|
||||
|
||||
List<AggregatedAttack> topSources = attacks
|
||||
.GroupBy(static attack => attack.SourceIp)
|
||||
.Select(group => AggregatedAttack.FromGroup(group))
|
||||
.OrderByDescending(static aggregate => aggregate.Count)
|
||||
.ThenBy(static aggregate => aggregate.SourceIp, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(options.TopCount)
|
||||
.ToList();
|
||||
|
||||
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration);
|
||||
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState);
|
||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
||||
? VulnerabilityCorrelationSummary.Empty()
|
||||
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
|
||||
CorrelationAssessment correlationAssessment = VulnerabilityCorrelation.Assess(baseAlertState, attacks.Count, vulnerabilityCorrelation, configuration);
|
||||
DateTimeOffset generatedAtLocal = DateTimeOffset.Now;
|
||||
DateTimeOffset generatedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
return new ScanResult
|
||||
{
|
||||
SchemaVersion = "2.0",
|
||||
MachineName = Environment.MachineName,
|
||||
GeneratedAtLocal = generatedAtLocal,
|
||||
GeneratedAtUtc = generatedAtUtc,
|
||||
ClientVersion = BuildMetadata.Version,
|
||||
LookbackDays = options.LookbackDays,
|
||||
TotalEvents = attacks.Count,
|
||||
UniqueIpCount = uniqueIpCount,
|
||||
AlertState = correlationAssessment.FinalAlertState,
|
||||
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
|
||||
BaseAlertState = baseAlertState,
|
||||
BaseAlertReason = baseAlertReason,
|
||||
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
||||
Runtime = new ScanRuntimeMetadata
|
||||
{
|
||||
StartedAtUtc = startedAtUtc,
|
||||
FinishedAtUtc = generatedAtUtc,
|
||||
UploadAttempted = false
|
||||
},
|
||||
Events = attacks,
|
||||
TopSources = topSources,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
private static ScannerConfiguration LoadConfiguration(ScanOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ConfigPath))
|
||||
{
|
||||
return new ScannerConfiguration();
|
||||
}
|
||||
|
||||
return ScannerConfiguration.Load(options.ConfigPath);
|
||||
}
|
||||
|
||||
private static void ScanWindowsLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
|
||||
{
|
||||
const string query = "*[System/EventID=4625]";
|
||||
TryScanEventLog("Security", query, errors, eventRecord =>
|
||||
{
|
||||
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string ip = ReadProperty(eventRecord, 19);
|
||||
if (string.IsNullOrWhiteSpace(ip) || ip == "-")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddAttack(attacks, new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
SourceIp = ip,
|
||||
Target = "Windows login",
|
||||
Username = ReadProperty(eventRecord, 5, "[unknown]"),
|
||||
Source = "Security",
|
||||
InstanceId = 4625
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScanSqlLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
|
||||
{
|
||||
const string query = "*[System/EventID=18456]";
|
||||
TryScanEventLog("Application", query, errors, eventRecord =>
|
||||
{
|
||||
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string ip = ExtractIp(ReadProperty(eventRecord, 2));
|
||||
if (string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddAttack(attacks, new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
SourceIp = ip,
|
||||
Target = "SQL Server",
|
||||
Username = ReadProperty(eventRecord, 0, "[unknown]"),
|
||||
Source = "Application",
|
||||
InstanceId = 18456
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScanExchangeLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
|
||||
{
|
||||
const string query = "*[System/EventID=1035]";
|
||||
TryScanEventLog("Application", query, errors, eventRecord =>
|
||||
{
|
||||
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string ip = ReadProperty(eventRecord, 3);
|
||||
if (string.IsNullOrWhiteSpace(ip) || ip.Length <= 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddAttack(attacks, new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
SourceIp = ip,
|
||||
Target = "Exchange",
|
||||
Username = "[not logged]",
|
||||
Source = "Application",
|
||||
InstanceId = 1035
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
||||
foreach (string root in roots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string directory in Directory.GetDirectories(root, "FTP*"))
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(directory, "*.log"))
|
||||
{
|
||||
ParseIisFtpLogFile(file, attacks, errors, since);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"FTP scan failed for {root}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseIisFtpLogFile(string filePath, List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
|
||||
{
|
||||
string username = "[unknown]";
|
||||
|
||||
try
|
||||
{
|
||||
foreach (string line in File.ReadLines(filePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 9)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
$"{parts[0]} {parts[1]}",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime timestampUtc))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var timestamp = new DateTimeOffset(timestampUtc).ToLocalTime();
|
||||
if (timestamp < since)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string sourceIp = parts[2];
|
||||
string command = parts[6];
|
||||
string parameter = parts[7];
|
||||
string status = parts[8];
|
||||
|
||||
if (string.Equals(command, "USER", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
username = parameter;
|
||||
}
|
||||
else if (string.Equals(command, "PASS", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(status, "230", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddAttack(attacks, new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
SourceIp = sourceIp,
|
||||
Target = "FTP login",
|
||||
Username = username,
|
||||
Source = "IIS FTP",
|
||||
InstanceId = -1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"FTP log parse failed for {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanFileZillaLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
IEnumerable<string> roots = configuration.FileZillaRoots.Count > 0 ? configuration.FileZillaRoots : DefaultFileZillaRoots;
|
||||
foreach (string root in roots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string file in Directory.GetFiles(root, "*.log"))
|
||||
{
|
||||
ParseFileZillaLogFile(file, attacks, errors, since);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"FileZilla scan failed for {root}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseFileZillaLogFile(string filePath, List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
|
||||
{
|
||||
string username = "[unknown]";
|
||||
|
||||
try
|
||||
{
|
||||
foreach (string line in File.ReadLines(filePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int closeParen = line.IndexOf(')');
|
||||
if (closeParen < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string remainder = line[(closeParen + 1)..];
|
||||
int dash = remainder.IndexOf('-');
|
||||
if (dash < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string timestampText = remainder[..dash].Trim();
|
||||
remainder = remainder[(dash + 1)..].TrimStart();
|
||||
|
||||
if (!DateTime.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedTimestamp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DateTimeOffset timestamp = new(parsedTimestamp);
|
||||
if (timestamp < since)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remainder.Contains("(not logged in)", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int idx = remainder.IndexOf(')');
|
||||
if (idx >= 0 && idx + 1 < remainder.Length)
|
||||
{
|
||||
remainder = remainder[(idx + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
int openIp = remainder.IndexOf('(');
|
||||
int closeIp = remainder.IndexOf(')');
|
||||
if (openIp < 0 || closeIp <= openIp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string ip = remainder[(openIp + 1)..closeIp].Trim();
|
||||
string message = remainder[(closeIp + 1)..].Trim();
|
||||
|
||||
if (message.Contains("password incorrect", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddAttack(attacks, new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
SourceIp = ip,
|
||||
Target = "FileZilla FTP login",
|
||||
Username = username,
|
||||
Source = "FileZilla",
|
||||
InstanceId = -1
|
||||
});
|
||||
}
|
||||
else if (message.Contains("> USER", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int userIndex = message.IndexOf("> USER", StringComparison.OrdinalIgnoreCase);
|
||||
username = message[(userIndex + 6)..].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"FileZilla log parse failed for {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryScanEventLog(string logName, string query, List<string> errors, Action<EventRecord> processRecord)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query));
|
||||
for (EventRecord? eventRecord = reader.ReadEvent(); eventRecord != null; eventRecord = reader.ReadEvent())
|
||||
{
|
||||
using (eventRecord)
|
||||
{
|
||||
processRecord(eventRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Event log scan failed for {logName} ({query}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetTimestamp(EventRecord eventRecord, DateTimeOffset since, out DateTimeOffset timestamp)
|
||||
{
|
||||
if (!eventRecord.TimeCreated.HasValue)
|
||||
{
|
||||
timestamp = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
timestamp = new DateTimeOffset(eventRecord.TimeCreated.Value);
|
||||
return timestamp >= since;
|
||||
}
|
||||
|
||||
private static string ReadProperty(EventRecord eventRecord, int index, string fallback = "")
|
||||
{
|
||||
if (index < 0 || index >= eventRecord.Properties.Count)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
object? value = eventRecord.Properties[index].Value;
|
||||
return value?.ToString()?.Trim() ?? fallback;
|
||||
}
|
||||
|
||||
private static void AddAttack(List<AttackEvent> attacks, AttackEvent attack)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attack.SourceIp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LooksLikeIpAddress(attack.SourceIp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
attacks.Add(attack with { SourceIp = attack.SourceIp.Trim() });
|
||||
}
|
||||
|
||||
private static string ExtractIp(string input)
|
||||
{
|
||||
Match match = Regex.Match(input, @"\b(?:\d{1,3}\.){3}\d{1,3}\b");
|
||||
return match.Success ? match.Value : string.Empty;
|
||||
}
|
||||
|
||||
private static bool LooksLikeIpAddress(string input)
|
||||
{
|
||||
return IPAddress.TryParse(input, out _);
|
||||
}
|
||||
|
||||
private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration)
|
||||
{
|
||||
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold)
|
||||
{
|
||||
return "critical";
|
||||
}
|
||||
|
||||
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold)
|
||||
{
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState)
|
||||
{
|
||||
return alertState switch
|
||||
{
|
||||
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
|
||||
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
|
||||
_ => "No thresholds exceeded."
|
||||
};
|
||||
}
|
||||
}
|
||||
23
src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj
Normal file
23
src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>OCSentinelCli</AssemblyName>
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.2.3</Version>
|
||||
<AssemblyVersion>1.2.3.0</AssemblyVersion>
|
||||
<FileVersion>1.2.3.0</FileVersion>
|
||||
<InformationalVersion>1.2.3</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Diagnostics.EventLog" Version="10.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
11
src/AttackTracerNinjaCli/BuildMetadata.cs
Normal file
11
src/AttackTracerNinjaCli/BuildMetadata.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal static class BuildMetadata
|
||||
{
|
||||
public static string Version =>
|
||||
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
|
||||
?? "0.0.0";
|
||||
}
|
||||
41
src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs
Normal file
41
src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace AttackTracerNinjaCli.Commands;
|
||||
|
||||
internal static class ScanAndUploadCommand
|
||||
{
|
||||
public static int Execute(string[] args)
|
||||
{
|
||||
string outputPath = @"C:\ProgramData\AttackTracerNinja\reports\latest.json";
|
||||
bool hasOutput = false;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "--output", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
outputPath = args[i + 1];
|
||||
hasOutput = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<string> scanArgs = [.. args];
|
||||
if (!hasOutput)
|
||||
{
|
||||
scanArgs.Add("--output");
|
||||
scanArgs.Add(outputPath);
|
||||
}
|
||||
|
||||
int scanExitCode = ScanCommand.Execute([.. scanArgs]);
|
||||
if (scanExitCode != 0)
|
||||
{
|
||||
return scanExitCode;
|
||||
}
|
||||
|
||||
var uploadArgs = new List<string>
|
||||
{
|
||||
"--report",
|
||||
outputPath
|
||||
};
|
||||
|
||||
return UploadCommand.Execute([.. uploadArgs]);
|
||||
}
|
||||
}
|
||||
138
src/AttackTracerNinjaCli/Commands/ScanCommand.cs
Normal file
138
src/AttackTracerNinjaCli/Commands/ScanCommand.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace AttackTracerNinjaCli.Commands;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class ScanCommand
|
||||
{
|
||||
public static int Execute(string[] args)
|
||||
{
|
||||
ScanOptions options;
|
||||
|
||||
try
|
||||
{
|
||||
options = ScanOptions.Parse(args);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(ScanOptions.Usage);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (options.ShowHelp)
|
||||
{
|
||||
Console.WriteLine(ScanOptions.Usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scanner = new AttackScanner();
|
||||
ScanResult result = scanner.Run(options);
|
||||
string json = JsonSerializer.Serialize(result, JsonOptions.Default);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.OutputPath))
|
||||
{
|
||||
string outputPath = Path.GetFullPath(options.OutputPath);
|
||||
string? outputDirectory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
File.WriteAllText(outputPath, json);
|
||||
}
|
||||
|
||||
WriteConsoleOutput(result, options, json);
|
||||
|
||||
if (options.FailOnThreshold && !string.Equals(result.AlertState, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return options.FailOnAttacks && result.TotalEvents > 0 ? 1 : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Scanner failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteConsoleOutput(ScanResult result, ScanOptions options, string json)
|
||||
{
|
||||
if (!options.JsonOnly)
|
||||
{
|
||||
Console.WriteLine($"OfficeCom Sentinel summary for {result.MachineName}");
|
||||
Console.WriteLine($"Generated: {result.GeneratedAtLocal:yyyy-MM-dd HH:mm:ss}");
|
||||
Console.WriteLine($"Events: {result.TotalEvents}");
|
||||
Console.WriteLine($"Unique IPs: {result.UniqueIpCount}");
|
||||
Console.WriteLine($"Status: {result.AlertState}");
|
||||
Console.WriteLine($"Reason: {result.AlertReason}");
|
||||
Console.WriteLine($"Base status: {result.BaseAlertState}");
|
||||
Console.WriteLine($"Scan errors: {result.Errors.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (result.TopSources.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No attacks found in the selected lookback window.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Top source IPs:");
|
||||
for (int i = 0; i < result.TopSources.Count; i++)
|
||||
{
|
||||
AggregatedAttack aggregate = result.TopSources[i];
|
||||
Console.WriteLine($"{i + 1}. {aggregate.SourceIp} | {aggregate.Count} hits | {aggregate.FirstSeenLocal:yyyy-MM-dd HH:mm:ss} -> {aggregate.LastSeenLocal:yyyy-MM-dd HH:mm:ss} | {aggregate.RateLabel}");
|
||||
Console.WriteLine($" Targets: {string.Join(", ", aggregate.Targets)} | Users: {string.Join(", ", aggregate.Usernames)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.VulnerabilityCorrelation.TotalCount > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Vulnerability correlation:");
|
||||
Console.WriteLine($"CVE findings for this host: {result.VulnerabilityCorrelation.TotalCount}");
|
||||
Console.WriteLine($"Critical/High findings: {result.VulnerabilityCorrelation.CriticalCount}");
|
||||
Console.WriteLine($"CVSS >= 8 findings: {result.VulnerabilityCorrelation.HighCvssCount}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.OutputPath))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"JSON report written to {Path.GetFullPath(options.OutputPath)}");
|
||||
}
|
||||
|
||||
if (result.Errors.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Warnings:");
|
||||
foreach (string error in result.Errors)
|
||||
{
|
||||
Console.WriteLine($"- {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
|
||||
if (options.NinjaOutput)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"ATTACKTRACER_STATUS={result.AlertState}");
|
||||
Console.WriteLine($"ATTACKTRACER_REASON={result.AlertReason}");
|
||||
Console.WriteLine($"ATTACKTRACER_BASE_STATUS={result.BaseAlertState}");
|
||||
Console.WriteLine($"ATTACKTRACER_EVENTS={result.TotalEvents}");
|
||||
Console.WriteLine($"ATTACKTRACER_UNIQUE_IPS={result.UniqueIpCount}");
|
||||
Console.WriteLine($"ATTACKTRACER_ERRORS={result.Errors.Count}");
|
||||
Console.WriteLine($"ATTACKTRACER_CVE_TOTAL={result.VulnerabilityCorrelation.TotalCount}");
|
||||
Console.WriteLine($"ATTACKTRACER_CVE_CRITICAL={result.VulnerabilityCorrelation.CriticalCount}");
|
||||
Console.WriteLine($"ATTACKTRACER_CVE_HIGH_CVSS={result.VulnerabilityCorrelation.HighCvssCount}");
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/AttackTracerNinjaCli/Commands/UploadCommand.cs
Normal file
100
src/AttackTracerNinjaCli/Commands/UploadCommand.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using AttackTracerNinjaCli.Configuration;
|
||||
using System.Text.Json;
|
||||
using AttackTracerNinjaCli.Security;
|
||||
using AttackTracerNinjaCli.Transport;
|
||||
|
||||
namespace AttackTracerNinjaCli.Commands;
|
||||
|
||||
internal static class UploadCommand
|
||||
{
|
||||
public static int Execute(string[] args)
|
||||
{
|
||||
string? reportPath = null;
|
||||
string? configPath = null;
|
||||
string? secretPath = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--report":
|
||||
reportPath = ReadValue(args, ref i, "--report");
|
||||
break;
|
||||
case "--client-config":
|
||||
configPath = ReadValue(args, ref i, "--client-config");
|
||||
break;
|
||||
case "--secret-path":
|
||||
secretPath = ReadValue(args, ref i, "--secret-path");
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
Console.WriteLine("Usage: OCSentinelCli upload --report <path> [--client-config <path>] [--secret-path <path>]");
|
||||
return 0;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown argument: {args[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reportPath))
|
||||
{
|
||||
throw new ArgumentException("Missing required argument: --report");
|
||||
}
|
||||
|
||||
string resolvedConfigPath = Path.GetFullPath(configPath ?? @"C:\ProgramData\OCSentinel\config\ocsentinel-client.json");
|
||||
string resolvedSecretPath = Path.GetFullPath(secretPath ?? @"C:\ProgramData\OCSentinel\secrets\upload-secret.dat");
|
||||
ClientConfiguration config = ClientConfiguration.Load(resolvedConfigPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.N8nWebhookUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Client configuration does not define n8nWebhookUrl.");
|
||||
}
|
||||
|
||||
if (!File.Exists(resolvedSecretPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Protected upload secret file not found: {resolvedSecretPath}");
|
||||
}
|
||||
|
||||
string reportFullPath = Path.GetFullPath(reportPath);
|
||||
string json = File.ReadAllText(reportFullPath);
|
||||
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
||||
if (parsedReport is not null)
|
||||
{
|
||||
parsedReport = parsedReport with
|
||||
{
|
||||
Runtime = parsedReport.Runtime with
|
||||
{
|
||||
UploadAttempted = true
|
||||
}
|
||||
};
|
||||
|
||||
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
||||
File.WriteAllText(reportFullPath, json);
|
||||
}
|
||||
|
||||
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
||||
var client = new N8nUploadClient();
|
||||
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
||||
Console.WriteLine($"Nonce: {result.Nonce}");
|
||||
Console.WriteLine($"Payload SHA256: {result.PayloadSha256}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string ReadValue(string[] args, ref int index, string argName)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
{
|
||||
throw new ArgumentException($"Missing value for {argName}");
|
||||
}
|
||||
|
||||
index++;
|
||||
return args[index];
|
||||
}
|
||||
}
|
||||
10
src/AttackTracerNinjaCli/Commands/VersionCommand.cs
Normal file
10
src/AttackTracerNinjaCli/Commands/VersionCommand.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace AttackTracerNinjaCli.Commands;
|
||||
|
||||
internal static class VersionCommand
|
||||
{
|
||||
public static int Execute()
|
||||
{
|
||||
Console.WriteLine(BuildMetadata.Version);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
32
src/AttackTracerNinjaCli/Configuration.cs
Normal file
32
src/AttackTracerNinjaCli/Configuration.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal sealed record ScannerConfiguration
|
||||
{
|
||||
public int WarningEventThreshold { get; init; } = 1;
|
||||
|
||||
public int CriticalEventThreshold { get; init; } = 20;
|
||||
|
||||
public int WarningUniqueIpThreshold { get; init; } = 1;
|
||||
|
||||
public int CriticalUniqueIpThreshold { get; init; } = 10;
|
||||
|
||||
public int CorrelationWarningCveThreshold { get; init; } = 1;
|
||||
|
||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||
|
||||
public List<string> FtpRoots { get; init; } = [];
|
||||
|
||||
public List<string> FileZillaRoots { get; init; } = [];
|
||||
|
||||
public List<string> ExcludedIps { get; init; } = [];
|
||||
|
||||
public static ScannerConfiguration Load(string path)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string json = File.ReadAllText(fullPath);
|
||||
ScannerConfiguration? config = JsonSerializer.Deserialize<ScannerConfiguration>(json, JsonOptions.Default);
|
||||
return config ?? new ScannerConfiguration();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AttackTracerNinjaCli.Configuration;
|
||||
|
||||
internal sealed record ClientConfiguration
|
||||
{
|
||||
public string SchemaVersion { get; init; } = "2.0";
|
||||
|
||||
public string Environment { get; init; } = "production";
|
||||
|
||||
public int LookbackDays { get; init; } = 7;
|
||||
|
||||
public int TopFindings { get; init; } = 10;
|
||||
|
||||
public string N8nWebhookUrl { get; init; } = string.Empty;
|
||||
|
||||
public string DeviceIdentifierMode { get; init; } = "machineName";
|
||||
|
||||
public int UploadTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
||||
|
||||
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
||||
|
||||
public string SecretReference { get; init; } = "device-default";
|
||||
|
||||
public static ClientConfiguration Load(string path)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string json = File.ReadAllText(fullPath);
|
||||
ClientConfiguration? config = JsonSerializer.Deserialize<ClientConfiguration>(json, JsonOptions.Default);
|
||||
return config ?? new ClientConfiguration();
|
||||
}
|
||||
}
|
||||
13
src/AttackTracerNinjaCli/JsonOptions.cs
Normal file
13
src/AttackTracerNinjaCli/JsonOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal static class JsonOptions
|
||||
{
|
||||
public static readonly JsonSerializerOptions Default = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
177
src/AttackTracerNinjaCli/Models.cs
Normal file
177
src/AttackTracerNinjaCli/Models.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal sealed record AttackEvent
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
public string SourceIp { get; init; } = string.Empty;
|
||||
|
||||
public long InstanceId { get; init; }
|
||||
|
||||
public string Target { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Source { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record AggregatedAttack
|
||||
{
|
||||
public string SourceIp { get; init; } = string.Empty;
|
||||
|
||||
public int Count { get; init; }
|
||||
|
||||
public DateTimeOffset FirstSeenLocal { get; init; }
|
||||
|
||||
public DateTimeOffset LastSeenLocal { get; init; }
|
||||
|
||||
public string RateLabel { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Targets { get; init; } = [];
|
||||
|
||||
public List<string> Usernames { get; init; } = [];
|
||||
|
||||
public List<string> Sources { get; init; } = [];
|
||||
|
||||
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
||||
{
|
||||
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
||||
DateTimeOffset firstSeen = ordered[0].Timestamp;
|
||||
DateTimeOffset lastSeen = ordered[^1].Timestamp;
|
||||
|
||||
return new AggregatedAttack
|
||||
{
|
||||
SourceIp = group.Key,
|
||||
Count = ordered.Count,
|
||||
FirstSeenLocal = firstSeen,
|
||||
LastSeenLocal = lastSeen,
|
||||
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
||||
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatRate(int count, DateTimeOffset firstSeen, DateTimeOffset lastSeen)
|
||||
{
|
||||
int seconds = (int)Math.Max(1, (lastSeen - firstSeen).TotalSeconds);
|
||||
if (seconds <= 1)
|
||||
{
|
||||
return $"{count * 60}/min";
|
||||
}
|
||||
|
||||
if (seconds <= 60)
|
||||
{
|
||||
return $"{count * 60 / seconds}/min";
|
||||
}
|
||||
|
||||
if (seconds <= 3600)
|
||||
{
|
||||
return $"{count * 3600 / seconds}/hour";
|
||||
}
|
||||
|
||||
return $"{count * 86400 / seconds}/day";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ScanResult
|
||||
{
|
||||
public string SchemaVersion { get; init; } = "2.0";
|
||||
|
||||
public string MachineName { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset GeneratedAtLocal { get; init; }
|
||||
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
|
||||
public string ClientVersion { get; init; } = string.Empty;
|
||||
|
||||
public int LookbackDays { get; init; }
|
||||
|
||||
public int TotalEvents { get; init; }
|
||||
|
||||
public int UniqueIpCount { get; init; }
|
||||
|
||||
public string AlertState { get; init; } = "ok";
|
||||
|
||||
public string AlertReason { get; init; } = "No thresholds exceeded.";
|
||||
|
||||
public string BaseAlertState { get; init; } = "ok";
|
||||
|
||||
public string BaseAlertReason { get; init; } = "No thresholds exceeded.";
|
||||
|
||||
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
|
||||
|
||||
public ScanRuntimeMetadata Runtime { get; init; } = new();
|
||||
|
||||
public List<AttackEvent> Events { get; init; } = [];
|
||||
|
||||
public List<AggregatedAttack> TopSources { get; init; } = [];
|
||||
|
||||
public List<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record ScanRuntimeMetadata
|
||||
{
|
||||
public DateTimeOffset StartedAtUtc { get; init; }
|
||||
|
||||
public DateTimeOffset FinishedAtUtc { get; init; }
|
||||
|
||||
public bool UploadAttempted { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record VulnerabilityFinding
|
||||
{
|
||||
public string DeviceName { get; init; } = string.Empty;
|
||||
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
public string Remediation { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record VulnerabilityCorrelationSummary
|
||||
{
|
||||
public string SourcePath { get; init; } = string.Empty;
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
public int CriticalCount { get; init; }
|
||||
|
||||
public int HighCvssCount { get; init; }
|
||||
|
||||
public List<VulnerabilityFinding> Findings { get; init; } = [];
|
||||
|
||||
public static VulnerabilityCorrelationSummary Empty(string sourcePath = "")
|
||||
{
|
||||
return new VulnerabilityCorrelationSummary { SourcePath = sourcePath };
|
||||
}
|
||||
|
||||
public static VulnerabilityCorrelationSummary FromFindings(string sourcePath, List<VulnerabilityFinding> findings)
|
||||
{
|
||||
int criticalCount = findings.Count(f =>
|
||||
string.Equals(f.Severity, "critical", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(f.Severity, "high", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
int highCvssCount = findings.Count(f => f.CvssScore.HasValue && f.CvssScore.Value >= 8.0);
|
||||
|
||||
return new VulnerabilityCorrelationSummary
|
||||
{
|
||||
SourcePath = sourcePath,
|
||||
TotalCount = findings.Count,
|
||||
CriticalCount = criticalCount,
|
||||
HighCvssCount = highCvssCount,
|
||||
Findings = findings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CorrelationAssessment
|
||||
{
|
||||
public string FinalAlertState { get; init; } = "ok";
|
||||
|
||||
public string CorrelationReason { get; init; } = "No CVE correlation applied.";
|
||||
}
|
||||
14
src/AttackTracerNinjaCli/Models/UploadResult.cs
Normal file
14
src/AttackTracerNinjaCli/Models/UploadResult.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace AttackTracerNinjaCli.Models;
|
||||
|
||||
internal sealed record UploadResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public int StatusCode { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string Nonce { get; init; } = string.Empty;
|
||||
|
||||
public string PayloadSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
37
src/AttackTracerNinjaCli/Program.cs
Normal file
37
src/AttackTracerNinjaCli/Program.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Runtime.Versioning;
|
||||
using AttackTracerNinjaCli.Commands;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
return ScanCommand.Execute(args);
|
||||
}
|
||||
|
||||
string command = args[0];
|
||||
string[] remainingArgs = args[1..];
|
||||
|
||||
return command switch
|
||||
{
|
||||
"scan" => ScanCommand.Execute(remainingArgs),
|
||||
"upload" => UploadCommand.Execute(remainingArgs),
|
||||
"scan-and-upload" => ScanAndUploadCommand.Execute(remainingArgs),
|
||||
"version" => VersionCommand.Execute(),
|
||||
_ when command.StartsWith('-') || command.StartsWith('/') => ScanCommand.Execute(args),
|
||||
_ => ScanCommand.Execute(args)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Command failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/AttackTracerNinjaCli/ScanOptions.cs
Normal file
113
src/AttackTracerNinjaCli/ScanOptions.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal sealed record ScanOptions
|
||||
{
|
||||
public const string Usage = """
|
||||
Usage:
|
||||
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
|
||||
|
||||
Options:
|
||||
--output <path> Write the JSON report to the given file.
|
||||
--lookback-days <n> Only include events newer than now minus n days. Default: 30
|
||||
--top <n> Number of aggregated source IPs to show. Default: 10
|
||||
--config <path> Load thresholds, path overrides, and exclusions from JSON.
|
||||
--vulnerability-csv <path>
|
||||
Correlate local attack results with exported CVE data for this host.
|
||||
--json-only Print only JSON to stdout.
|
||||
--ninja-output Print extra key=value lines for RMM/Ninja-style parsing.
|
||||
--fail-on-attacks Return exit code 1 when attacks are found.
|
||||
--fail-on-threshold Return exit code 1 when status is warning or critical.
|
||||
--help Show this message.
|
||||
""";
|
||||
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
public int LookbackDays { get; init; } = 30;
|
||||
|
||||
public int TopCount { get; init; } = 10;
|
||||
|
||||
public bool JsonOnly { get; init; }
|
||||
|
||||
public bool NinjaOutput { get; init; }
|
||||
|
||||
public bool FailOnAttacks { get; init; }
|
||||
|
||||
public bool FailOnThreshold { get; init; }
|
||||
|
||||
public string? ConfigPath { get; init; }
|
||||
|
||||
public string? VulnerabilityCsvPath { get; init; }
|
||||
|
||||
public bool ShowHelp { get; init; }
|
||||
|
||||
public static ScanOptions Parse(string[] args)
|
||||
{
|
||||
var options = new ScanOptions();
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
string arg = args[i];
|
||||
switch (arg)
|
||||
{
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "/?":
|
||||
options = options with { ShowHelp = true };
|
||||
break;
|
||||
case "--json-only":
|
||||
options = options with { JsonOnly = true };
|
||||
break;
|
||||
case "--ninja-output":
|
||||
options = options with { NinjaOutput = true };
|
||||
break;
|
||||
case "--fail-on-attacks":
|
||||
options = options with { FailOnAttacks = true };
|
||||
break;
|
||||
case "--fail-on-threshold":
|
||||
options = options with { FailOnThreshold = true };
|
||||
break;
|
||||
case "--output":
|
||||
options = options with { OutputPath = ReadValue(args, ref i, arg) };
|
||||
break;
|
||||
case "--config":
|
||||
options = options with { ConfigPath = ReadValue(args, ref i, arg) };
|
||||
break;
|
||||
case "--vulnerability-csv":
|
||||
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
|
||||
break;
|
||||
case "--lookback-days":
|
||||
options = options with { LookbackDays = ReadPositiveInt(args, ref i, arg) };
|
||||
break;
|
||||
case "--top":
|
||||
options = options with { TopCount = ReadPositiveInt(args, ref i, arg) };
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown argument: {arg}");
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static string ReadValue(string[] args, ref int index, string argName)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
{
|
||||
throw new ArgumentException($"Missing value for {argName}");
|
||||
}
|
||||
|
||||
index++;
|
||||
return args[index];
|
||||
}
|
||||
|
||||
private static int ReadPositiveInt(string[] args, ref int index, string argName)
|
||||
{
|
||||
string raw = ReadValue(args, ref index, argName);
|
||||
if (!int.TryParse(raw, out int value) || value <= 0)
|
||||
{
|
||||
throw new ArgumentException($"{argName} must be a positive integer");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
28
src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs
Normal file
28
src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AttackTracerNinjaCli.Security;
|
||||
|
||||
internal static class ProtectedSecretStore
|
||||
{
|
||||
public static string LoadSecret(string path)
|
||||
{
|
||||
byte[] protectedBytes = File.ReadAllBytes(path);
|
||||
byte[] plainBytes = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.LocalMachine);
|
||||
return Encoding.UTF8.GetString(plainBytes);
|
||||
}
|
||||
|
||||
public static void SaveSecret(string path, string secret)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string? directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(secret);
|
||||
byte[] protectedBytes = ProtectedData.Protect(plainBytes, null, DataProtectionScope.LocalMachine);
|
||||
File.WriteAllBytes(fullPath, protectedBytes);
|
||||
}
|
||||
}
|
||||
70
src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs
Normal file
70
src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AttackTracerNinjaCli.Models;
|
||||
|
||||
namespace AttackTracerNinjaCli.Transport;
|
||||
|
||||
internal sealed class N8nUploadClient
|
||||
{
|
||||
public UploadResult UploadJson(
|
||||
string webhookUrl,
|
||||
string machineName,
|
||||
string clientVersion,
|
||||
string payloadJson,
|
||||
string sharedSecret,
|
||||
int timeoutSeconds)
|
||||
{
|
||||
string timestamp = DateTimeOffset.UtcNow.ToString("O");
|
||||
string nonce = Guid.NewGuid().ToString("N");
|
||||
string payloadHash = ComputeSha256(payloadJson);
|
||||
string signature = ComputeSignature(machineName, timestamp, nonce, clientVersion, payloadHash, sharedSecret);
|
||||
|
||||
using var httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds))
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
|
||||
{
|
||||
Content = new StringContent(payloadJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("X-ATN-Device", machineName);
|
||||
request.Headers.Add("X-ATN-Timestamp", timestamp);
|
||||
request.Headers.Add("X-ATN-Nonce", nonce);
|
||||
request.Headers.Add("X-ATN-Version", clientVersion);
|
||||
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
||||
request.Headers.Add("X-ATN-Signature", signature);
|
||||
|
||||
using HttpResponseMessage response = httpClient.Send(request);
|
||||
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new UploadResult
|
||||
{
|
||||
Success = response.IsSuccessStatusCode,
|
||||
StatusCode = (int)response.StatusCode,
|
||||
Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText,
|
||||
Nonce = nonce,
|
||||
PayloadSha256 = payloadHash
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(value);
|
||||
byte[] hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string machineName, string timestamp, string nonce, string version, string payloadHash, string sharedSecret)
|
||||
{
|
||||
string canonical = string.Join("\n", machineName, timestamp, nonce, version, payloadHash);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(sharedSecret);
|
||||
byte[] canonicalBytes = Encoding.UTF8.GetBytes(canonical);
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
byte[] hash = hmac.ComputeHash(canonicalBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
200
src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs
Normal file
200
src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace AttackTracerNinjaCli;
|
||||
|
||||
internal static class VulnerabilityCorrelation
|
||||
{
|
||||
private static readonly string[] DeviceNameHeaders = ["device", "device_name", "hostname", "computer", "computername", "endpoint", "machine", "system"];
|
||||
private static readonly string[] CveHeaders = ["cve", "cve_id", "cveid", "vulnerability", "vulnerability_id"];
|
||||
private static readonly string[] SeverityHeaders = ["severity", "risk", "level"];
|
||||
private static readonly string[] CvssHeaders = ["cvss", "cvss_score", "score", "base_score"];
|
||||
private static readonly string[] RemediationHeaders = ["remediation", "patch", "kb", "fix", "solution"];
|
||||
|
||||
public static VulnerabilityCorrelationSummary LoadForMachine(string machineName, string csvPath, List<string> errors)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fullPath = Path.GetFullPath(csvPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
errors.Add($"Vulnerability correlation file not found: {fullPath}");
|
||||
return VulnerabilityCorrelationSummary.Empty(fullPath);
|
||||
}
|
||||
|
||||
string[] lines = File.ReadAllLines(fullPath);
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
return VulnerabilityCorrelationSummary.Empty(fullPath);
|
||||
}
|
||||
|
||||
string[] headers = SplitCsvLine(lines[0]);
|
||||
var headerMap = headers
|
||||
.Select((header, index) => new { Header = header.Trim(), Index = index })
|
||||
.ToDictionary(static pair => Normalize(pair.Header), static pair => pair.Index, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int deviceIndex = FindHeaderIndex(headerMap, DeviceNameHeaders);
|
||||
int cveIndex = FindHeaderIndex(headerMap, CveHeaders);
|
||||
int severityIndex = FindHeaderIndex(headerMap, SeverityHeaders);
|
||||
int cvssIndex = FindHeaderIndex(headerMap, CvssHeaders);
|
||||
int remediationIndex = FindHeaderIndex(headerMap, RemediationHeaders);
|
||||
|
||||
if (deviceIndex < 0 || cveIndex < 0)
|
||||
{
|
||||
errors.Add($"Vulnerability correlation file is missing a device or CVE column: {fullPath}");
|
||||
return VulnerabilityCorrelationSummary.Empty(fullPath);
|
||||
}
|
||||
|
||||
var findings = new List<VulnerabilityFinding>();
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
string line = lines[i];
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] cells = SplitCsvLine(line);
|
||||
string deviceName = GetCell(cells, deviceIndex);
|
||||
if (!string.Equals(deviceName, machineName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string cveId = GetCell(cells, cveIndex);
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string severity = severityIndex >= 0 ? GetCell(cells, severityIndex) : string.Empty;
|
||||
string remediation = remediationIndex >= 0 ? GetCell(cells, remediationIndex) : string.Empty;
|
||||
double? cvss = TryParseDouble(cvssIndex >= 0 ? GetCell(cells, cvssIndex) : string.Empty);
|
||||
|
||||
findings.Add(new VulnerabilityFinding
|
||||
{
|
||||
DeviceName = deviceName,
|
||||
CveId = cveId,
|
||||
Severity = severity,
|
||||
CvssScore = cvss,
|
||||
Remediation = remediation
|
||||
});
|
||||
}
|
||||
|
||||
return VulnerabilityCorrelationSummary.FromFindings(fullPath, findings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Vulnerability correlation load failed for {csvPath}: {ex.Message}");
|
||||
return VulnerabilityCorrelationSummary.Empty(Path.GetFullPath(csvPath));
|
||||
}
|
||||
}
|
||||
|
||||
public static CorrelationAssessment Assess(
|
||||
string currentAlertState,
|
||||
int totalEvents,
|
||||
VulnerabilityCorrelationSummary vulnerabilitySummary,
|
||||
ScannerConfiguration configuration)
|
||||
{
|
||||
string finalState = currentAlertState;
|
||||
string reason = "No CVE correlation applied.";
|
||||
|
||||
bool hasAttacks = totalEvents > 0;
|
||||
bool hasCriticalCvEs = vulnerabilitySummary.CriticalCount >= configuration.CorrelationCriticalCveThreshold;
|
||||
bool hasWarningCvEs = vulnerabilitySummary.TotalCount >= configuration.CorrelationWarningCveThreshold;
|
||||
|
||||
if (hasAttacks && hasCriticalCvEs)
|
||||
{
|
||||
finalState = "critical";
|
||||
reason = $"Attack activity correlated with {vulnerabilitySummary.CriticalCount} critical/high CVE findings on this endpoint.";
|
||||
}
|
||||
else if (hasAttacks && hasWarningCvEs && string.Equals(finalState, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
finalState = "warning";
|
||||
reason = $"Attack activity correlated with {vulnerabilitySummary.TotalCount} CVE findings on this endpoint.";
|
||||
}
|
||||
else if (vulnerabilitySummary.TotalCount > 0)
|
||||
{
|
||||
reason = $"Loaded {vulnerabilitySummary.TotalCount} CVE findings for this endpoint, but no alert escalation was required.";
|
||||
}
|
||||
|
||||
return new CorrelationAssessment
|
||||
{
|
||||
FinalAlertState = finalState,
|
||||
CorrelationReason = reason
|
||||
};
|
||||
}
|
||||
|
||||
private static int FindHeaderIndex(Dictionary<string, int> headerMap, string[] candidates)
|
||||
{
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (headerMap.TryGetValue(Normalize(candidate), out int index))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
{
|
||||
return value.Trim().Replace(" ", "_").Replace("-", "_").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string[] SplitCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (c == '"')
|
||||
{
|
||||
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
current.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
}
|
||||
else if (c == ',' && !inQuotes)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(current.ToString());
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
private static string GetCell(string[] cells, int index)
|
||||
{
|
||||
return index >= 0 && index < cells.Length ? cells[index].Trim() : string.Empty;
|
||||
}
|
||||
|
||||
private static double? TryParseDouble(string value)
|
||||
{
|
||||
if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out double result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (double.TryParse(value, NumberStyles.Any, CultureInfo.CurrentCulture, out result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user