Files
git.stella-ops.org/src/Aoc/__Analyzers/StellaOps.Aoc.Analyzers/AocForbiddenFieldAnalyzer.cs
StellaOps Bot f46bde5575 save progress
2026-01-02 15:52:55 +02:00

632 lines
23 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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<string> 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<DiagnosticDescriptor> 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<string> 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; }
}
}