up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Merge;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and providing risk profiles from configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileConfigurationService
|
||||
{
|
||||
private readonly ILogger<RiskProfileConfigurationService> _logger;
|
||||
private readonly PolicyEngineRiskProfileOptions _options;
|
||||
private readonly RiskProfileMergeService _mergeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly RiskProfileValidator _validator;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _profileCache;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _resolvedCache;
|
||||
private readonly object _loadLock = new();
|
||||
private bool _loaded;
|
||||
|
||||
public RiskProfileConfigurationService(
|
||||
ILogger<RiskProfileConfigurationService> logger,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher();
|
||||
_validator = new RiskProfileValidator();
|
||||
_profileCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_resolvedCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether risk profile integration is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile ID.
|
||||
/// </summary>
|
||||
public string DefaultProfileId => _options.DefaultProfileId;
|
||||
|
||||
/// <summary>
|
||||
/// Loads all profiles from configuration and file system.
|
||||
/// </summary>
|
||||
public void LoadProfiles()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadInlineProfiles();
|
||||
LoadFileProfiles();
|
||||
EnsureDefaultProfile();
|
||||
|
||||
_loaded = true;
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} risk profiles (default: {DefaultId})",
|
||||
_profileCache.Count,
|
||||
_options.DefaultProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, resolving inheritance if needed.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to retrieve.</param>
|
||||
/// <returns>The resolved profile, or null if not found.</returns>
|
||||
public RiskProfileModel? GetProfile(string? profileId)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(profileId) ? _options.DefaultProfileId : profileId;
|
||||
|
||||
if (_options.CacheResolvedProfiles && _resolvedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!_profileCache.TryGetValue(id, out var profile))
|
||||
{
|
||||
_logger.LogWarning("Risk profile '{ProfileId}' not found", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = _mergeService.ResolveInheritance(
|
||||
profile,
|
||||
LookupProfile,
|
||||
_options.MaxInheritanceDepth);
|
||||
|
||||
if (_options.CacheResolvedProfiles)
|
||||
{
|
||||
_resolvedCache.TryAdd(id, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
public RiskProfileModel? GetDefaultProfile() => GetProfile(_options.DefaultProfileId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all loaded profile IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetProfileIds() => _profileCache.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic hash for a profile.
|
||||
/// </summary>
|
||||
public string ComputeHash(RiskProfileModel profile) => _hasher.ComputeHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content hash (ignoring identity fields) for a profile.
|
||||
/// </summary>
|
||||
public string ComputeContentHash(RiskProfileModel profile) => _hasher.ComputeContentHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a profile programmatically.
|
||||
/// </summary>
|
||||
public void RegisterProfile(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
_profileCache[profile.Id] = profile;
|
||||
_resolvedCache.TryRemove(profile.Id, out _);
|
||||
|
||||
_logger.LogDebug("Registered risk profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resolved profile cache.
|
||||
/// </summary>
|
||||
public void ClearResolvedCache()
|
||||
{
|
||||
_resolvedCache.Clear();
|
||||
_logger.LogDebug("Cleared resolved profile cache");
|
||||
}
|
||||
|
||||
private RiskProfileModel? LookupProfile(string id) =>
|
||||
_profileCache.TryGetValue(id, out var profile) ? profile : null;
|
||||
|
||||
private void LoadInlineProfiles()
|
||||
{
|
||||
foreach (var definition in _options.Profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = ConvertFromDefinition(definition);
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded inline profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load inline profile '{ProfileId}'", definition.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFileProfiles()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ProfileDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_options.ProfileDirectory))
|
||||
{
|
||||
_logger.LogWarning("Risk profile directory not found: {Directory}", _options.ProfileDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_options.ProfileDirectory, "*.json", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
|
||||
if (_options.ValidateOnLoad)
|
||||
{
|
||||
var validation = _validator.Validate(json);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Risk profile file '{File}' failed validation: {Errors}",
|
||||
file,
|
||||
string.Join("; ", validation.Message ?? "Unknown error"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = JsonSerializer.Deserialize<RiskProfileModel>(json, JsonOptions);
|
||||
if (profile != null)
|
||||
{
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded profile '{ProfileId}' from {File}", profile.Id, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load risk profile from '{File}'", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDefaultProfile()
|
||||
{
|
||||
if (_profileCache.ContainsKey(_options.DefaultProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultProfile = CreateBuiltInDefaultProfile();
|
||||
_profileCache[defaultProfile.Id] = defaultProfile;
|
||||
_logger.LogDebug("Created built-in default profile '{ProfileId}'", defaultProfile.Id);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateBuiltInDefaultProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "default",
|
||||
Version = "1.0.0",
|
||||
Description = "Built-in default risk profile with standard vulnerability signals.",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "cvss_score",
|
||||
Source = "vulnerability",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/cvss/baseScore",
|
||||
Unit = "score"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "kev",
|
||||
Source = "cisa",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/kev/inCatalog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "epss",
|
||||
Source = "first",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/epss/probability",
|
||||
Unit = "probability"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "reachability",
|
||||
Source = "analysis",
|
||||
Type = RiskSignalType.Categorical,
|
||||
Path = "/reachability/status"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "exploit_available",
|
||||
Source = "exploit-db",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/exploit/available"
|
||||
}
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss_score"] = 0.3,
|
||||
["kev"] = 0.25,
|
||||
["epss"] = 0.2,
|
||||
["reachability"] = 0.15,
|
||||
["exploit_available"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["builtin"] = true,
|
||||
["created"] = DateTimeOffset.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel ConvertFromDefinition(RiskProfileDefinition definition)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = definition.Id,
|
||||
Version = definition.Version,
|
||||
Description = definition.Description,
|
||||
Extends = definition.Extends,
|
||||
Signals = definition.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = ParseSignalType(s.Type),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(definition.Weights),
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = definition.Metadata != null
|
||||
? new Dictionary<string, object?>(definition.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignalType ParseSignalType(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => RiskSignalType.Boolean,
|
||||
"numeric" or "number" => RiskSignalType.Numeric,
|
||||
"categorical" or "category" => RiskSignalType.Categorical,
|
||||
_ => throw new ArgumentException($"Unknown signal type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user