330 lines
10 KiB
C#
330 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Cli.Services;
|
|
|
|
internal sealed class ScannerExecutor : IScannerExecutor
|
|
{
|
|
private readonly ILogger<ScannerExecutor> _logger;
|
|
|
|
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<ScannerExecutionResult> RunAsync(
|
|
string runner,
|
|
string entry,
|
|
string targetDirectory,
|
|
string resultsDirectory,
|
|
IReadOnlyList<string> arguments,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(targetDirectory))
|
|
{
|
|
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
|
|
}
|
|
|
|
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
|
|
entry = entry?.Trim() ?? string.Empty;
|
|
|
|
var normalizedTarget = Path.GetFullPath(targetDirectory);
|
|
if (!Directory.Exists(normalizedTarget))
|
|
{
|
|
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
|
|
}
|
|
|
|
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
|
|
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
|
|
: Path.GetFullPath(resultsDirectory);
|
|
|
|
Directory.CreateDirectory(resultsDirectory);
|
|
var executionTimestamp = DateTimeOffset.UtcNow;
|
|
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
|
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
|
|
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
|
|
|
var stdout = new List<string>();
|
|
var stderr = new List<string>();
|
|
|
|
process.OutputDataReceived += (_, args) =>
|
|
{
|
|
if (args.Data is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
stdout.Add(args.Data);
|
|
if (verbose)
|
|
{
|
|
_logger.LogInformation("[scan] {Line}", args.Data);
|
|
}
|
|
};
|
|
|
|
process.ErrorDataReceived += (_, args) =>
|
|
{
|
|
if (args.Data is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
stderr.Add(args.Data);
|
|
_logger.LogError("[scan] {Line}", args.Data);
|
|
};
|
|
|
|
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
|
|
if (!process.Start())
|
|
{
|
|
throw new InvalidOperationException("Failed to start scanner process.");
|
|
}
|
|
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
var completionTimestamp = DateTimeOffset.UtcNow;
|
|
|
|
if (process.ExitCode == 0)
|
|
{
|
|
_logger.LogInformation("Scanner completed successfully.");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
|
}
|
|
|
|
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
|
if (string.IsNullOrWhiteSpace(resultsPath))
|
|
{
|
|
resultsPath = CreatePlaceholderResult(resultsDirectory);
|
|
}
|
|
|
|
var metadataPath = WriteRunMetadata(
|
|
resultsDirectory,
|
|
executionTimestamp,
|
|
completionTimestamp,
|
|
runner,
|
|
entry,
|
|
normalizedTarget,
|
|
resultsPath,
|
|
arguments,
|
|
process.ExitCode,
|
|
stdout,
|
|
stderr);
|
|
|
|
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
|
|
}
|
|
|
|
private ProcessStartInfo BuildProcessStartInfo(
|
|
string runner,
|
|
string entry,
|
|
string targetDirectory,
|
|
string resultsDirectory,
|
|
IReadOnlyList<string> args)
|
|
{
|
|
return runner switch
|
|
{
|
|
"self" or "native" => BuildNativeStartInfo(entry, args),
|
|
"dotnet" => BuildDotNetStartInfo(entry, args),
|
|
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
|
|
_ => BuildCustomRunnerStartInfo(runner, entry, args)
|
|
};
|
|
}
|
|
|
|
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
|
|
{
|
|
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
|
|
}
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = binaryPath,
|
|
WorkingDirectory = Directory.GetCurrentDirectory()
|
|
};
|
|
|
|
foreach (var argument in args)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
startInfo.RedirectStandardError = true;
|
|
startInfo.RedirectStandardOutput = true;
|
|
startInfo.UseShellExecute = false;
|
|
|
|
return startInfo;
|
|
}
|
|
|
|
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "dotnet",
|
|
WorkingDirectory = Directory.GetCurrentDirectory()
|
|
};
|
|
|
|
startInfo.ArgumentList.Add(binaryPath);
|
|
foreach (var argument in args)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
startInfo.RedirectStandardError = true;
|
|
startInfo.RedirectStandardOutput = true;
|
|
startInfo.UseShellExecute = false;
|
|
|
|
return startInfo;
|
|
}
|
|
|
|
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(image))
|
|
{
|
|
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
|
|
}
|
|
|
|
var cwd = Directory.GetCurrentDirectory();
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "docker",
|
|
WorkingDirectory = cwd
|
|
};
|
|
|
|
startInfo.ArgumentList.Add("run");
|
|
startInfo.ArgumentList.Add("--rm");
|
|
startInfo.ArgumentList.Add("-v");
|
|
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
|
|
startInfo.ArgumentList.Add("-v");
|
|
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
|
|
startInfo.ArgumentList.Add("-v");
|
|
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
|
|
startInfo.ArgumentList.Add("-w");
|
|
startInfo.ArgumentList.Add(cwd);
|
|
startInfo.ArgumentList.Add(image);
|
|
startInfo.ArgumentList.Add("--target");
|
|
startInfo.ArgumentList.Add("/scan-target");
|
|
startInfo.ArgumentList.Add("--output");
|
|
startInfo.ArgumentList.Add("/scan-results/scan.json");
|
|
|
|
foreach (var argument in args)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
startInfo.RedirectStandardError = true;
|
|
startInfo.RedirectStandardOutput = true;
|
|
startInfo.UseShellExecute = false;
|
|
|
|
return startInfo;
|
|
}
|
|
|
|
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = runner,
|
|
WorkingDirectory = Directory.GetCurrentDirectory()
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(entry))
|
|
{
|
|
startInfo.ArgumentList.Add(entry);
|
|
}
|
|
|
|
foreach (var argument in args)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
startInfo.RedirectStandardError = true;
|
|
startInfo.RedirectStandardOutput = true;
|
|
startInfo.UseShellExecute = false;
|
|
|
|
return startInfo;
|
|
}
|
|
|
|
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
|
|
{
|
|
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
|
string? newest = null;
|
|
DateTimeOffset newestTimestamp = startTimestamp;
|
|
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (baseline.Contains(candidate))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var info = new FileInfo(candidate);
|
|
if (info.LastWriteTimeUtc >= newestTimestamp)
|
|
{
|
|
newestTimestamp = info.LastWriteTimeUtc;
|
|
newest = candidate;
|
|
}
|
|
}
|
|
|
|
return newest ?? string.Empty;
|
|
}
|
|
|
|
private static string CreatePlaceholderResult(string resultsDirectory)
|
|
{
|
|
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
|
var path = Path.Combine(resultsDirectory, fileName);
|
|
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
|
return path;
|
|
}
|
|
|
|
private static string WriteRunMetadata(
|
|
string resultsDirectory,
|
|
DateTimeOffset startedAt,
|
|
DateTimeOffset completedAt,
|
|
string runner,
|
|
string entry,
|
|
string targetDirectory,
|
|
string resultsPath,
|
|
IReadOnlyList<string> arguments,
|
|
int exitCode,
|
|
IReadOnlyList<string> stdout,
|
|
IReadOnlyList<string> stderr)
|
|
{
|
|
var duration = completedAt - startedAt;
|
|
var payload = new
|
|
{
|
|
runner,
|
|
entry,
|
|
targetDirectory,
|
|
resultsPath,
|
|
arguments,
|
|
exitCode,
|
|
startedAt = startedAt,
|
|
completedAt = completedAt,
|
|
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
|
|
stdout,
|
|
stderr
|
|
};
|
|
|
|
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
|
|
var path = Path.Combine(resultsDirectory, fileName);
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
var json = JsonSerializer.Serialize(payload, options);
|
|
File.WriteAllText(path, json);
|
|
return path;
|
|
}
|
|
}
|