using System; using System.Collections.Generic; using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; namespace StellaOps.Aoc.Analyzers; /// /// Roslyn analyzer that detects writes to AOC-forbidden fields during ingestion. /// This prevents accidental overwrites of derived/computed fields that should only /// be set by the merge/decisioning pipeline. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer { public const string DiagnosticIdForbiddenField = "AOC0001"; public const string DiagnosticIdDerivedField = "AOC0002"; public const string DiagnosticIdUnguardedWrite = "AOC0003"; private const string IngestionAllOption = "stellaops_aoc_ingestion"; private const string IngestionAssemblyOption = "stellaops_aoc_ingestion_assemblies"; private const string IngestionNamespaceOption = "stellaops_aoc_ingestion_namespace_prefixes"; private static readonly ImmutableHashSet ForbiddenTopLevel = ImmutableHashSet.Create( StringComparer.OrdinalIgnoreCase, "severity", "cvss", "cvss_vector", "effective_status", "effective_range", "merged_from", "consensus_provider", "reachability", "asset_criticality", "risk_score"); private static readonly DiagnosticDescriptor ForbiddenFieldRule = new( DiagnosticIdForbiddenField, title: "AOC forbidden field write detected", messageFormat: "Field '{0}' is forbidden in AOC ingestion context; this field is computed by the decisioning pipeline (ERR_AOC_001)", category: "AOC", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, description: "AOC (Append-Only Contracts) forbid writes to certain fields during ingestion. These fields are computed by downstream merge/decisioning pipelines and must not be set during initial data capture.", helpLinkUri: "https://stella-ops.org/docs/aoc/forbidden-fields"); private static readonly DiagnosticDescriptor DerivedFieldRule = new( DiagnosticIdDerivedField, title: "AOC derived field write detected", messageFormat: "Derived field '{0}' must not be written during ingestion; effective_* fields are computed post-merge (ERR_AOC_006)", category: "AOC", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, description: "Fields prefixed with 'effective_' are derived values computed after merge. Writing them during ingestion violates append-only contracts.", helpLinkUri: "https://stella-ops.org/docs/aoc/derived-fields"); private static readonly DiagnosticDescriptor UnguardedWriteRule = new( DiagnosticIdUnguardedWrite, title: "AOC unguarded database write detected", messageFormat: "Database write operation '{0}' detected without AOC guard validation; wrap with IAocGuard.Validate() (ERR_AOC_007)", category: "AOC", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "All database writes in ingestion pipelines should be validated by the AOC guard to ensure forbidden fields are not written.", helpLinkUri: "https://stella-ops.org/docs/aoc/guard-usage"); public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(ForbiddenFieldRule, DerivedFieldRule, UnguardedWriteRule); public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterCompilationStartAction(startContext => { var symbols = new AnalyzerTypeSymbols(startContext.Compilation); startContext.RegisterOperationAction(ctx => AnalyzeAssignment(ctx, symbols), OperationKind.SimpleAssignment); startContext.RegisterOperationAction(ctx => AnalyzePropertyReference(ctx, symbols), OperationKind.PropertyReference); startContext.RegisterOperationAction(ctx => AnalyzeInvocation(ctx, symbols), OperationKind.Invocation); startContext.RegisterSyntaxNodeAction(ctx => AnalyzeObjectInitializer(ctx, symbols), SyntaxKind.ObjectInitializerExpression); startContext.RegisterSyntaxNodeAction(ctx => AnalyzeAnonymousObjectMember(ctx, symbols), SyntaxKind.AnonymousObjectMemberDeclarator); }); } private static void AnalyzeAssignment(OperationAnalysisContext context, AnalyzerTypeSymbols symbols) { if (context.Operation is not ISimpleAssignmentOperation assignment) { return; } if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols)) { return; } var targetName = GetTargetPropertyName(assignment.Target); if (string.IsNullOrEmpty(targetName)) { return; } CheckForbiddenField(context, targetName!, assignment.Syntax.GetLocation()); } private static void AnalyzePropertyReference(OperationAnalysisContext context, AnalyzerTypeSymbols symbols) { if (context.Operation is not IPropertyReferenceOperation propertyRef) { return; } if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols)) { return; } if (!IsWriteContext(propertyRef)) { return; } var propertyName = propertyRef.Property.Name; CheckForbiddenField(context, propertyName, propertyRef.Syntax.GetLocation()); } private static void AnalyzeInvocation(OperationAnalysisContext context, AnalyzerTypeSymbols symbols) { if (context.Operation is not IInvocationOperation invocation) { return; } if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols)) { return; } var method = invocation.TargetMethod; var methodName = method.Name; // Check for dictionary/document indexer writes with forbidden keys if (IsDictionarySetOperation(method)) { CheckDictionaryWriteArguments(context, invocation); return; } // Check for unguarded database write operations if (IsDatabaseWriteOperation(method, symbols)) { if (!IsWithinAocGuardScope(invocation, symbols)) { var diagnostic = Diagnostic.Create( UnguardedWriteRule, invocation.Syntax.GetLocation(), $"{method.ContainingType?.Name}.{methodName}"); context.ReportDiagnostic(diagnostic); } } } private static void AnalyzeObjectInitializer(SyntaxNodeAnalysisContext context, AnalyzerTypeSymbols symbols) { var initializer = (InitializerExpressionSyntax)context.Node; if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols)) { return; } foreach (var expression in initializer.Expressions) { if (expression is AssignmentExpressionSyntax assignment) { var left = assignment.Left; string? propertyName = left switch { IdentifierNameSyntax identifier => identifier.Identifier.Text, _ => null }; if (!string.IsNullOrEmpty(propertyName)) { CheckForbiddenFieldSyntax(context, propertyName!, left.GetLocation()); } } } } private static void AnalyzeAnonymousObjectMember(SyntaxNodeAnalysisContext context, AnalyzerTypeSymbols symbols) { var member = (AnonymousObjectMemberDeclaratorSyntax)context.Node; if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols)) { return; } var name = member.NameEquals?.Name.Identifier.Text; if (!string.IsNullOrEmpty(name)) { CheckForbiddenFieldSyntax(context, name!, member.GetLocation()); } } private static void CheckForbiddenField(OperationAnalysisContext context, string fieldName, Location location) { if (ForbiddenTopLevel.Contains(fieldName)) { var diagnostic = Diagnostic.Create(ForbiddenFieldRule, location, fieldName); context.ReportDiagnostic(diagnostic); return; } if (fieldName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase)) { var diagnostic = Diagnostic.Create(DerivedFieldRule, location, fieldName); context.ReportDiagnostic(diagnostic); } } private static void CheckForbiddenFieldSyntax(SyntaxNodeAnalysisContext context, string fieldName, Location location) { if (ForbiddenTopLevel.Contains(fieldName)) { var diagnostic = Diagnostic.Create(ForbiddenFieldRule, location, fieldName); context.ReportDiagnostic(diagnostic); return; } if (fieldName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase)) { var diagnostic = Diagnostic.Create(DerivedFieldRule, location, fieldName); context.ReportDiagnostic(diagnostic); } } private static void CheckDictionaryWriteArguments(OperationAnalysisContext context, IInvocationOperation invocation) { foreach (var argument in invocation.Arguments) { if (argument.Value is ILiteralOperation literal && literal.ConstantValue.HasValue) { var value = literal.ConstantValue.Value?.ToString(); if (!string.IsNullOrEmpty(value)) { CheckForbiddenField(context, value!, argument.Syntax.GetLocation()); } } } } private static string? GetTargetPropertyName(IOperation? target) { return target switch { IPropertyReferenceOperation propRef => propRef.Property.Name, IFieldReferenceOperation fieldRef => fieldRef.Field.Name, ILocalReferenceOperation localRef => localRef.Local.Name, _ => null }; } private static bool IsWriteContext(IPropertyReferenceOperation propertyRef) { var parent = propertyRef.Parent; return parent is ISimpleAssignmentOperation assignment && assignment.Target == propertyRef; } private static bool IsIngestionContext(ISymbol? containingSymbol, AnalyzerOptions options, AnalyzerTypeSymbols symbols) { if (containingSymbol is null) { return false; } var assemblyName = containingSymbol.ContainingAssembly?.Name; if (string.IsNullOrEmpty(assemblyName)) { return false; } // Allow analyzer assemblies and tests if (assemblyName!.EndsWith(".Analyzers", StringComparison.Ordinal) || assemblyName.EndsWith(".Tests", StringComparison.Ordinal) || assemblyName.EndsWith(".Test", StringComparison.Ordinal) || assemblyName.EndsWith(".Testing", StringComparison.Ordinal)) { return false; } if (HasIngestionMarker(containingSymbol) || HasIngestionMarker(containingSymbol.ContainingType) || HasIngestionMarker(containingSymbol.ContainingAssembly)) { return true; } if (IsConfigIngestionAssembly(assemblyName, options)) { return true; } if (IsConfigIngestionNamespace(containingSymbol.ContainingNamespace?.ToDisplayString(), options)) { return true; } // Check for ingestion-related assemblies/namespaces if (assemblyName.Contains(".Connector.", StringComparison.Ordinal) || assemblyName.Contains(".Ingestion", StringComparison.Ordinal) || assemblyName.EndsWith(".Connector", StringComparison.Ordinal)) { return true; } // Check namespace for ingestion context var ns = containingSymbol.ContainingNamespace?.ToDisplayString(); if (!string.IsNullOrEmpty(ns)) { if (ns!.Contains(".Connector.", StringComparison.Ordinal) || ns.Contains(".Ingestion", StringComparison.Ordinal)) { return true; } } return false; } private static bool HasIngestionMarker(ISymbol? symbol) { if (symbol is null) { return false; } foreach (var attribute in symbol.GetAttributes()) { var attributeName = attribute.AttributeClass?.Name; if (string.Equals(attributeName, "AocIngestionAttribute", StringComparison.Ordinal) || string.Equals(attributeName, "AocIngestionContextAttribute", StringComparison.Ordinal)) { return true; } } return false; } private static bool IsConfigIngestionAssembly(string assemblyName, AnalyzerOptions options) { if (IsConfigIngestionEnabledForAll(options)) { return true; } if (TryGetOption(options, IngestionAssemblyOption, out var assemblies) || TryGetOption(options, "build_property.StellaOpsAocIngestionAssemblies", out assemblies)) { foreach (var name in SplitOptionValue(assemblies)) { if (string.Equals(name, "*", StringComparison.Ordinal) || string.Equals(name, "all", StringComparison.OrdinalIgnoreCase) || string.Equals(name, assemblyName, StringComparison.Ordinal)) { return true; } } } return false; } private static bool IsConfigIngestionNamespace(string? ns, AnalyzerOptions options) { if (string.IsNullOrWhiteSpace(ns)) { return false; } var namespaceValue = ns!; if (TryGetOption(options, IngestionNamespaceOption, out var namespaces) || TryGetOption(options, "build_property.StellaOpsAocIngestionNamespacePrefixes", out namespaces)) { foreach (var prefix in SplitOptionValue(namespaces)) { if (namespaceValue.StartsWith(prefix, StringComparison.Ordinal)) { return true; } } } return false; } private static bool IsConfigIngestionEnabledForAll(AnalyzerOptions options) { if (TryGetOption(options, IngestionAllOption, out var value) || TryGetOption(options, "build_property.StellaOpsAocIngestion", out value)) { if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "all", StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private static bool TryGetOption(AnalyzerOptions options, string key, out string value) { value = string.Empty; if (!options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) { return false; } value = raw.Trim(); return value.Length > 0; } private static IEnumerable SplitOptionValue(string value) { foreach (var entry in value.Split(new[] { ';', ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { var trimmed = entry.Trim(); if (!string.IsNullOrEmpty(trimmed)) { yield return trimmed; } } } private static bool IsDictionarySetOperation(IMethodSymbol method) { var name = method.Name; if (!string.Equals(name, "set_Item", StringComparison.Ordinal) && !string.Equals(name, "Add", StringComparison.Ordinal) && !string.Equals(name, "TryAdd", StringComparison.Ordinal) && !string.Equals(name, "Set", StringComparison.Ordinal)) { return false; } var containingType = method.ContainingType; if (containingType is null) { return false; } var typeName = containingType.ToDisplayString(); return typeName.Contains("Dictionary", StringComparison.Ordinal) || typeName.Contains("BsonDocument", StringComparison.Ordinal) || typeName.Contains("JsonObject", StringComparison.Ordinal) || typeName.Contains("JsonElement", StringComparison.Ordinal); } private static bool IsDatabaseWriteOperation(IMethodSymbol method, AnalyzerTypeSymbols symbols) { var name = method.Name; if ((string.Equals(name, "SaveChanges", StringComparison.Ordinal) || string.Equals(name, "SaveChangesAsync", StringComparison.Ordinal)) && IsOnTypeOrDerived(method.ContainingType, symbols.DbContext)) { return true; } if ((string.Equals(name, "Add", StringComparison.Ordinal) || string.Equals(name, "AddAsync", StringComparison.Ordinal) || string.Equals(name, "Update", StringComparison.Ordinal) || string.Equals(name, "UpdateAsync", StringComparison.Ordinal)) && IsOnTypeOrDerived(method.ContainingType, symbols.DbSet)) { return true; } if ((string.Equals(name, "ExecuteNonQuery", StringComparison.Ordinal) || string.Equals(name, "ExecuteNonQueryAsync", StringComparison.Ordinal)) && (IsOnTypeOrDerived(method.ContainingType, symbols.DbCommand) || IsOnTypeOrDerived(method.ContainingType, symbols.NpgsqlCommand))) { return true; } if ((string.Equals(name, "InsertOne", StringComparison.Ordinal) || string.Equals(name, "InsertOneAsync", StringComparison.Ordinal) || string.Equals(name, "InsertMany", StringComparison.Ordinal) || string.Equals(name, "InsertManyAsync", StringComparison.Ordinal) || string.Equals(name, "UpdateOne", StringComparison.Ordinal) || string.Equals(name, "UpdateOneAsync", StringComparison.Ordinal) || string.Equals(name, "UpdateMany", StringComparison.Ordinal) || string.Equals(name, "UpdateManyAsync", StringComparison.Ordinal) || string.Equals(name, "ReplaceOne", StringComparison.Ordinal) || string.Equals(name, "ReplaceOneAsync", StringComparison.Ordinal) || string.Equals(name, "BulkWrite", StringComparison.Ordinal) || string.Equals(name, "BulkWriteAsync", StringComparison.Ordinal)) && IsOnTypeOrDerived(method.ContainingType, symbols.MongoCollection)) { return true; } return false; } private static bool IsWithinAocGuardScope(IInvocationOperation invocation, AnalyzerTypeSymbols symbols) { // Walk up the operation tree to find if we're within an AOC guard validation scope var current = invocation.Parent; var depth = 0; const int maxDepth = 20; while (current is not null && depth < maxDepth) { if (current is IInvocationOperation parentInvocation) { var method = parentInvocation.TargetMethod; if ((method.Name == "Validate" || method.Name == "ValidateOrThrow") && IsAocGuardInvocation(method, symbols)) { return true; } } // Check if containing method has IAocGuard parameter or calls Validate if (current is IBlockOperation) { // We've reached a method body; check the containing method signature var containingMethod = invocation.SemanticModel?.GetEnclosingSymbol(invocation.Syntax.SpanStart) as IMethodSymbol; if (containingMethod is not null) { foreach (var param in containingMethod.Parameters) { if (IsAocGuardType(param.Type, symbols)) { return true; } } } } current = current.Parent; depth++; } return false; } private static bool IsOnTypeOrDerived(INamedTypeSymbol? type, INamedTypeSymbol? expected) { if (type is null || expected is null) { return false; } for (var current = type; current is not null; current = current.BaseType) { if (SymbolEqualityComparer.Default.Equals(current, expected)) { return true; } } foreach (var iface in type.AllInterfaces) { if (SymbolEqualityComparer.Default.Equals(iface, expected)) { return true; } } return false; } private static bool IsAocGuardType(ITypeSymbol? type, AnalyzerTypeSymbols symbols) { if (type is null) { return false; } if (symbols.AocGuard is not null && IsOnTypeOrDerived(type as INamedTypeSymbol, symbols.AocGuard)) { return true; } return type.Name.Contains("AocGuard", StringComparison.Ordinal); } private static bool IsAocGuardInvocation(IMethodSymbol method, AnalyzerTypeSymbols symbols) { if (IsAocGuardType(method.ContainingType, symbols)) { return true; } if (method.IsExtensionMethod && method.Parameters.Length > 0) { return IsAocGuardType(method.Parameters[0].Type, symbols); } return false; } private sealed class AnalyzerTypeSymbols { public AnalyzerTypeSymbols(Compilation compilation) { AocGuard = compilation.GetTypeByMetadataName("StellaOps.Aoc.IAocGuard"); DbContext = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbContext"); DbSet = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbSet`1"); DbCommand = compilation.GetTypeByMetadataName("System.Data.Common.DbCommand"); NpgsqlCommand = compilation.GetTypeByMetadataName("Npgsql.NpgsqlCommand"); MongoCollection = compilation.GetTypeByMetadataName("MongoDB.Driver.IMongoCollection`1"); } public INamedTypeSymbol? AocGuard { get; } public INamedTypeSymbol? DbContext { get; } public INamedTypeSymbol? DbSet { get; } public INamedTypeSymbol? DbCommand { get; } public INamedTypeSymbol? NpgsqlCommand { get; } public INamedTypeSymbol? MongoCollection { get; } } }