Remove legacy AttackTracer repo content
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled

This commit is contained in:
OfficeCom Codex
2026-07-17 00:55:16 +02:00
parent 85e394f647
commit b5e06b885a
57 changed files with 98 additions and 3602 deletions

View 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 OCSentinelCli;
[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."
};
}
}

View File

@@ -0,0 +1,11 @@
using System.Reflection;
namespace OCSentinelCli;
internal static class BuildMetadata
{
public static string Version =>
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
?? "0.0.0";
}

View File

@@ -0,0 +1,41 @@
namespace OCSentinelCli.Commands;
internal static class ScanAndUploadCommand
{
public static int Execute(string[] args)
{
string outputPath = @"C:\ProgramData\OCSentinel\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]);
}
}

View File

@@ -0,0 +1,138 @@
using System.Text.Json;
using System.Runtime.Versioning;
namespace OCSentinelCli.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}");
}
}
}

View File

@@ -0,0 +1,100 @@
using OCSentinelCli.Configuration;
using System.Text.Json;
using OCSentinelCli.Security;
using OCSentinelCli.Transport;
namespace OCSentinelCli.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\ocsentinel-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];
}
}

View File

@@ -0,0 +1,10 @@
namespace OCSentinelCli.Commands;
internal static class VersionCommand
{
public static int Execute()
{
Console.WriteLine(BuildMetadata.Version);
return 0;
}
}

View File

@@ -0,0 +1,32 @@
using System.Text.Json;
namespace OCSentinelCli;
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();
}
}

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
namespace OCSentinelCli.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();
}
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OCSentinelCli;
internal static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}

177
src/OCSentinelCli/Models.cs Normal file
View File

@@ -0,0 +1,177 @@
namespace OCSentinelCli;
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.";
}

View File

@@ -0,0 +1,14 @@
namespace OCSentinelCli.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;
}

View 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>

View File

@@ -0,0 +1,37 @@
using System.Runtime.Versioning;
using OCSentinelCli.Commands;
namespace OCSentinelCli;
[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;
}
}
}

View File

@@ -0,0 +1,113 @@
namespace OCSentinelCli;
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;
}
}

View File

@@ -0,0 +1,28 @@
using System.Security.Cryptography;
using System.Text;
namespace OCSentinelCli.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);
}
}

View File

@@ -0,0 +1,70 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using OCSentinelCli.Models;
namespace OCSentinelCli.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();
}
}

View File

@@ -0,0 +1,200 @@
using System.Globalization;
namespace OCSentinelCli;
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;
}
}