feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
@@ -334,4 +334,13 @@ public sealed class ScannerWorkerMetrics
|
||||
|
||||
return tags.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records native binary analysis metrics.
|
||||
/// </summary>
|
||||
public void RecordNativeAnalysis(NativeAnalysisResult result)
|
||||
{
|
||||
// Native analysis metrics are tracked via counters/histograms
|
||||
// This is a placeholder for when we add dedicated native analysis metrics
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NativeAnalyzerOptions.cs
|
||||
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
|
||||
// Task: NAI-004
|
||||
// Description: Configuration options for native binary analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for native binary analysis during container scans.
|
||||
/// </summary>
|
||||
public sealed class NativeAnalyzerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:Worker:NativeAnalyzers";
|
||||
|
||||
/// <summary>
|
||||
/// Whether native binary analysis is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Directories to search for native analyzer plugins.
|
||||
/// </summary>
|
||||
public IList<string> PluginDirectories { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Paths to exclude from binary discovery.
|
||||
/// Common system paths that contain kernel interfaces or virtual filesystems.
|
||||
/// </summary>
|
||||
public IList<string> ExcludePaths { get; } = new List<string>
|
||||
{
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/run"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of binaries to analyze per container layer.
|
||||
/// Prevents performance issues with containers containing many binaries.
|
||||
/// </summary>
|
||||
public int MaxBinariesPerLayer { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total binaries to analyze per scan.
|
||||
/// </summary>
|
||||
public int MaxBinariesPerScan { get; set; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable heuristic detection for binaries without file extensions.
|
||||
/// </summary>
|
||||
public bool EnableHeuristics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to extract hardening flags from binaries.
|
||||
/// </summary>
|
||||
public bool ExtractHardeningFlags { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to look up Build-IDs in the index for package correlation.
|
||||
/// </summary>
|
||||
public bool EnableBuildIdLookup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// File extensions to consider as potential binaries.
|
||||
/// </summary>
|
||||
public IList<string> BinaryExtensions { get; } = new List<string>
|
||||
{
|
||||
".so",
|
||||
".dll",
|
||||
".exe",
|
||||
".dylib",
|
||||
".a",
|
||||
".o"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for analyzing a single binary.
|
||||
/// </summary>
|
||||
public TimeSpan SingleBinaryTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the entire native analysis phase.
|
||||
/// </summary>
|
||||
public TimeSpan TotalAnalysisTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum file size to consider as a binary (bytes).
|
||||
/// </summary>
|
||||
public long MinFileSizeBytes { get; set; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to analyze (bytes). Larger files are skipped.
|
||||
/// </summary>
|
||||
public long MaxFileSizeBytes { get; set; } = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include unresolved binaries (no Build-ID match) in SBOM output.
|
||||
/// </summary>
|
||||
public bool IncludeUnresolvedInSbom { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Degree of parallelism for binary analysis.
|
||||
/// </summary>
|
||||
public int MaxDegreeOfParallelism { get; set; } = 4;
|
||||
}
|
||||
@@ -28,6 +28,8 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public AnalyzerOptions Analyzers { get; } = new();
|
||||
|
||||
public NativeAnalyzerOptions NativeAnalyzers { get; } = new();
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
public SigningOptions Signing { get; } = new();
|
||||
|
||||
@@ -152,19 +152,23 @@ public sealed class EpssIngestJob : BackgroundService
|
||||
: _onlineSource;
|
||||
|
||||
// Retrieve the EPSS file
|
||||
var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false);
|
||||
await using var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Read file content and compute hash
|
||||
var fileContent = await File.ReadAllBytesAsync(sourceFile.LocalPath, cancellationToken).ConfigureAwait(false);
|
||||
var fileSha256 = ComputeSha256(fileContent);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Retrieved EPSS file from {SourceUri}, size={Size}",
|
||||
sourceFile.SourceUri,
|
||||
sourceFile.Content.Length);
|
||||
fileContent.Length);
|
||||
|
||||
// Begin import run
|
||||
var importRun = await _repository.BeginImportAsync(
|
||||
modelDate,
|
||||
sourceFile.SourceUri,
|
||||
_timeProvider.GetUtcNow(),
|
||||
sourceFile.FileSha256,
|
||||
fileSha256,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Created import run {ImportRunId}", importRun.ImportRunId);
|
||||
@@ -172,7 +176,7 @@ public sealed class EpssIngestJob : BackgroundService
|
||||
try
|
||||
{
|
||||
// Parse and write snapshot
|
||||
await using var stream = new MemoryStream(sourceFile.Content);
|
||||
await using var stream = new MemoryStream(fileContent);
|
||||
var session = _parser.ParseGzip(stream);
|
||||
|
||||
var writeResult = await _repository.WriteSnapshotAsync(
|
||||
@@ -269,4 +273,10 @@ public sealed class EpssIngestJob : BackgroundService
|
||||
|
||||
return new DateTimeOffset(scheduledTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] content)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(content);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NativeAnalyzerExecutor.cs
|
||||
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
|
||||
// Task: NAI-001
|
||||
// Description: Executes native binary analysis during container scans.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Emit.Native;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Executes native binary analysis during container scans.
|
||||
/// Discovers binaries, extracts metadata, correlates with Build-ID index,
|
||||
/// and emits SBOM components.
|
||||
/// </summary>
|
||||
public sealed class NativeAnalyzerExecutor
|
||||
{
|
||||
private readonly NativeBinaryDiscovery _discovery;
|
||||
private readonly INativeComponentEmitter _emitter;
|
||||
private readonly NativeAnalyzerOptions _options;
|
||||
private readonly ILogger<NativeAnalyzerExecutor> _logger;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
|
||||
public NativeAnalyzerExecutor(
|
||||
NativeBinaryDiscovery discovery,
|
||||
INativeComponentEmitter emitter,
|
||||
IOptions<NativeAnalyzerOptions> options,
|
||||
ILogger<NativeAnalyzerExecutor> logger,
|
||||
ScannerWorkerMetrics metrics)
|
||||
{
|
||||
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
|
||||
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes native binaries in the container filesystem.
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Path to the extracted container filesystem.</param>
|
||||
/// <param name="context">Scan job context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Analysis result with discovered components.</returns>
|
||||
public async Task<NativeAnalysisResult> ExecuteAsync(
|
||||
string rootPath,
|
||||
ScanJobContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Native analyzer is disabled");
|
||||
return NativeAnalysisResult.Empty;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.TotalAnalysisTimeout);
|
||||
|
||||
// Discover binaries
|
||||
var discovered = await _discovery.DiscoverAsync(rootPath, cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (discovered.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No native binaries discovered in {RootPath}", rootPath);
|
||||
return NativeAnalysisResult.Empty;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting native analysis of {Count} binaries for job {JobId}",
|
||||
discovered.Count,
|
||||
context.JobId);
|
||||
|
||||
// Convert to metadata and emit
|
||||
var metadataList = new List<NativeBinaryMetadata>(discovered.Count);
|
||||
foreach (var binary in discovered)
|
||||
{
|
||||
var metadata = await ExtractMetadataAsync(binary, cts.Token).ConfigureAwait(false);
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadataList.Add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch emit components
|
||||
var emitResults = await _emitter.EmitBatchAsync(metadataList, cts.Token).ConfigureAwait(false);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
var result = new NativeAnalysisResult
|
||||
{
|
||||
DiscoveredCount = discovered.Count,
|
||||
AnalyzedCount = metadataList.Count,
|
||||
ResolvedCount = emitResults.Count(r => r.IndexMatch),
|
||||
UnresolvedCount = emitResults.Count(r => !r.IndexMatch),
|
||||
Components = emitResults,
|
||||
ElapsedMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
_metrics.RecordNativeAnalysis(result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Native analysis complete for job {JobId}: {Resolved}/{Analyzed} resolved in {ElapsedMs}ms",
|
||||
context.JobId,
|
||||
result.ResolvedCount,
|
||||
result.AnalyzedCount,
|
||||
result.ElapsedMs);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Native analysis timed out for job {JobId} after {ElapsedMs}ms",
|
||||
context.JobId,
|
||||
sw.ElapsedMilliseconds);
|
||||
|
||||
return new NativeAnalysisResult
|
||||
{
|
||||
TimedOut = true,
|
||||
ElapsedMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Native analysis failed for job {JobId}", context.JobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NativeBinaryMetadata?> ExtractMetadataAsync(
|
||||
DiscoveredBinary binary,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.SingleBinaryTimeout);
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
// Read binary header to extract Build-ID and other metadata
|
||||
var buildId = ExtractBuildId(binary);
|
||||
|
||||
return new NativeBinaryMetadata
|
||||
{
|
||||
Format = binary.Format.ToString().ToLowerInvariant(),
|
||||
FilePath = binary.RelativePath,
|
||||
BuildId = buildId,
|
||||
Architecture = DetectArchitecture(binary),
|
||||
Platform = DetectPlatform(binary)
|
||||
};
|
||||
}, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Extraction timed out for binary: {Path}", binary.RelativePath);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to extract metadata from: {Path}", binary.RelativePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string? ExtractBuildId(DiscoveredBinary binary)
|
||||
{
|
||||
if (binary.Format != BinaryFormat.Elf)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read ELF to find .note.gnu.build-id section
|
||||
using var fs = File.OpenRead(binary.AbsolutePath);
|
||||
using var reader = new BinaryReader(fs);
|
||||
|
||||
// Skip to ELF header
|
||||
var magic = reader.ReadBytes(4);
|
||||
if (magic.Length < 4 ||
|
||||
magic[0] != 0x7F || magic[1] != 0x45 || magic[2] != 0x4C || magic[3] != 0x46)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var elfClass = reader.ReadByte(); // 1 = 32-bit, 2 = 64-bit
|
||||
var is64Bit = elfClass == 2;
|
||||
|
||||
// Skip to section headers (simplified - real implementation would parse properly)
|
||||
// For now, return null - full implementation is in the Analyzers.Native project
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DetectArchitecture(DiscoveredBinary binary)
|
||||
{
|
||||
if (binary.Format != BinaryFormat.Elf)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(binary.AbsolutePath);
|
||||
Span<byte> header = stackalloc byte[20];
|
||||
if (fs.Read(header) < 20)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// e_machine is at offset 18 (2 bytes, little-endian typically)
|
||||
var machine = BitConverter.ToUInt16(header[18..20]);
|
||||
|
||||
return machine switch
|
||||
{
|
||||
0x03 => "i386",
|
||||
0x3E => "x86_64",
|
||||
0x28 => "arm",
|
||||
0xB7 => "aarch64",
|
||||
0xF3 => "riscv",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DetectPlatform(DiscoveredBinary binary)
|
||||
{
|
||||
return binary.Format switch
|
||||
{
|
||||
BinaryFormat.Elf => "linux",
|
||||
BinaryFormat.Pe => "windows",
|
||||
BinaryFormat.MachO => "darwin",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of native binary analysis.
|
||||
/// </summary>
|
||||
public sealed record NativeAnalysisResult
|
||||
{
|
||||
public static readonly NativeAnalysisResult Empty = new();
|
||||
|
||||
/// <summary>Number of binaries discovered in filesystem.</summary>
|
||||
public int DiscoveredCount { get; init; }
|
||||
|
||||
/// <summary>Number of binaries successfully analyzed.</summary>
|
||||
public int AnalyzedCount { get; init; }
|
||||
|
||||
/// <summary>Number of binaries resolved via Build-ID index.</summary>
|
||||
public int ResolvedCount { get; init; }
|
||||
|
||||
/// <summary>Number of binaries not found in Build-ID index.</summary>
|
||||
public int UnresolvedCount { get; init; }
|
||||
|
||||
/// <summary>Whether the analysis timed out.</summary>
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
/// <summary>Total elapsed time in milliseconds.</summary>
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
/// <summary>Emitted component results.</summary>
|
||||
public IReadOnlyList<NativeComponentEmitResult> Components { get; init; } = Array.Empty<NativeComponentEmitResult>();
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NativeBinaryDiscovery.cs
|
||||
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
|
||||
// Task: NAI-002
|
||||
// Description: Discovers native binaries in container filesystem layers.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers native binaries in container filesystem layers for analysis.
|
||||
/// </summary>
|
||||
public sealed class NativeBinaryDiscovery
|
||||
{
|
||||
private readonly NativeAnalyzerOptions _options;
|
||||
private readonly ILogger<NativeBinaryDiscovery> _logger;
|
||||
|
||||
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7FELF
|
||||
private static readonly byte[] PeMagic = [0x4D, 0x5A]; // MZ
|
||||
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE];
|
||||
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF];
|
||||
private static readonly byte[] MachO32MagicReverse = [0xCE, 0xFA, 0xED, 0xFE];
|
||||
private static readonly byte[] MachO64MagicReverse = [0xCF, 0xFA, 0xED, 0xFE];
|
||||
private static readonly byte[] FatMachOMagic = [0xCA, 0xFE, 0xBA, 0xBE];
|
||||
|
||||
public NativeBinaryDiscovery(
|
||||
IOptions<NativeAnalyzerOptions> options,
|
||||
ILogger<NativeBinaryDiscovery> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers binaries in the specified root filesystem path.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DiscoveredBinary>> DiscoverAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
|
||||
if (!Directory.Exists(rootPath))
|
||||
{
|
||||
_logger.LogWarning("Root path does not exist: {RootPath}", rootPath);
|
||||
return Array.Empty<DiscoveredBinary>();
|
||||
}
|
||||
|
||||
var discovered = new List<DiscoveredBinary>();
|
||||
var excludeSet = new HashSet<string>(_options.ExcludePaths, StringComparer.OrdinalIgnoreCase);
|
||||
var extensionSet = new HashSet<string>(
|
||||
_options.BinaryExtensions.Select(e => e.StartsWith('.') ? e : "." + e),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
DiscoverRecursive(
|
||||
rootPath,
|
||||
rootPath,
|
||||
discovered,
|
||||
excludeSet,
|
||||
extensionSet,
|
||||
cancellationToken);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} native binaries in {RootPath}",
|
||||
discovered.Count,
|
||||
rootPath);
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
private void DiscoverRecursive(
|
||||
string basePath,
|
||||
string currentPath,
|
||||
List<DiscoveredBinary> discovered,
|
||||
HashSet<string> excludeSet,
|
||||
HashSet<string> extensionSet,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Check if we've hit the limit
|
||||
if (discovered.Count >= _options.MaxBinariesPerScan)
|
||||
{
|
||||
_logger.LogDebug("Reached max binaries per scan limit ({Limit})", _options.MaxBinariesPerScan);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get relative path for exclusion check
|
||||
var relativePath = GetRelativePath(basePath, currentPath);
|
||||
if (IsExcluded(relativePath, excludeSet))
|
||||
{
|
||||
_logger.LogDebug("Skipping excluded path: {Path}", relativePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enumerate files
|
||||
IEnumerable<string> files;
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(currentPath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug("Access denied to directory: {Path}", currentPath);
|
||||
return;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (discovered.Count >= _options.MaxBinariesPerScan)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var binary = TryDiscoverBinary(basePath, filePath, extensionSet);
|
||||
if (binary is not null)
|
||||
{
|
||||
discovered.Add(binary);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not analyze file: {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
IEnumerable<string> directories;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(currentPath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
DiscoverRecursive(basePath, directory, discovered, excludeSet, extensionSet, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private DiscoveredBinary? TryDiscoverBinary(
|
||||
string basePath,
|
||||
string filePath,
|
||||
HashSet<string> extensionSet)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
// Size checks
|
||||
if (fileInfo.Length < _options.MinFileSizeBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileInfo.Length > _options.MaxFileSizeBytes)
|
||||
{
|
||||
_logger.LogDebug("File too large ({Size} bytes): {FilePath}", fileInfo.Length, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extension check (if heuristics disabled)
|
||||
var extension = Path.GetExtension(filePath);
|
||||
var hasKnownExtension = !string.IsNullOrEmpty(extension) && extensionSet.Contains(extension);
|
||||
|
||||
if (!_options.EnableHeuristics && !hasKnownExtension)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Magic byte check
|
||||
var format = DetectBinaryFormat(filePath);
|
||||
if (format == BinaryFormat.Unknown)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = GetRelativePath(basePath, filePath);
|
||||
|
||||
return new DiscoveredBinary(
|
||||
AbsolutePath: filePath,
|
||||
RelativePath: relativePath,
|
||||
Format: format,
|
||||
SizeBytes: fileInfo.Length,
|
||||
FileName: fileInfo.Name);
|
||||
}
|
||||
|
||||
private BinaryFormat DetectBinaryFormat(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
using var fs = File.OpenRead(filePath);
|
||||
if (fs.Read(header) < 4)
|
||||
{
|
||||
return BinaryFormat.Unknown;
|
||||
}
|
||||
|
||||
if (header.SequenceEqual(ElfMagic))
|
||||
{
|
||||
return BinaryFormat.Elf;
|
||||
}
|
||||
|
||||
if (header[..2].SequenceEqual(PeMagic))
|
||||
{
|
||||
return BinaryFormat.Pe;
|
||||
}
|
||||
|
||||
if (header.SequenceEqual(MachO32Magic) ||
|
||||
header.SequenceEqual(MachO64Magic) ||
|
||||
header.SequenceEqual(MachO32MagicReverse) ||
|
||||
header.SequenceEqual(MachO64MagicReverse) ||
|
||||
header.SequenceEqual(FatMachOMagic))
|
||||
{
|
||||
return BinaryFormat.MachO;
|
||||
}
|
||||
|
||||
return BinaryFormat.Unknown;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BinaryFormat.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRelativePath(string basePath, string fullPath)
|
||||
{
|
||||
if (fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = fullPath[basePath.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return "/" + relative.Replace('\\', '/');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static bool IsExcluded(string relativePath, HashSet<string> excludeSet)
|
||||
{
|
||||
foreach (var exclude in excludeSet)
|
||||
{
|
||||
if (relativePath.StartsWith(exclude, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith("/" + exclude.TrimStart('/'), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discovered binary file ready for analysis.
|
||||
/// </summary>
|
||||
/// <param name="AbsolutePath">Full path to the binary.</param>
|
||||
/// <param name="RelativePath">Path relative to the container root.</param>
|
||||
/// <param name="Format">Detected binary format.</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
/// <param name="FileName">File name only.</param>
|
||||
public sealed record DiscoveredBinary(
|
||||
string AbsolutePath,
|
||||
string RelativePath,
|
||||
BinaryFormat Format,
|
||||
long SizeBytes,
|
||||
string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Binary format types.
|
||||
/// </summary>
|
||||
public enum BinaryFormat
|
||||
{
|
||||
Unknown,
|
||||
Elf,
|
||||
Pe,
|
||||
MachO
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user