up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
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 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.RegisterOperationAction(AnalyzeAssignment, OperationKind.SimpleAssignment);
|
||||
context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference);
|
||||
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeObjectInitializer, SyntaxKind.ObjectInitializerExpression);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeAnonymousObjectMember, SyntaxKind.AnonymousObjectMemberDeclarator);
|
||||
}
|
||||
|
||||
private static void AnalyzeAssignment(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not ISimpleAssignmentOperation assignment)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetName = GetTargetPropertyName(assignment.Target);
|
||||
if (string.IsNullOrEmpty(targetName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CheckForbiddenField(context, targetName!, assignment.Syntax.GetLocation());
|
||||
}
|
||||
|
||||
private static void AnalyzePropertyReference(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IPropertyReferenceOperation propertyRef)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsWriteContext(propertyRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var propertyName = propertyRef.Property.Name;
|
||||
CheckForbiddenField(context, propertyName, propertyRef.Syntax.GetLocation());
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IInvocationOperation invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
{
|
||||
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))
|
||||
{
|
||||
if (!IsWithinAocGuardScope(invocation))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
UnguardedWriteRule,
|
||||
invocation.Syntax.GetLocation(),
|
||||
$"{method.ContainingType?.Name}.{methodName}");
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeObjectInitializer(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var initializer = (InitializerExpressionSyntax)context.Node;
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
{
|
||||
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)
|
||||
{
|
||||
var member = (AnonymousObjectMemberDeclaratorSyntax)context.Node;
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
{
|
||||
var name = method.Name;
|
||||
var writeOps = new[]
|
||||
{
|
||||
"InsertOne", "InsertOneAsync",
|
||||
"InsertMany", "InsertManyAsync",
|
||||
"UpdateOne", "UpdateOneAsync",
|
||||
"UpdateMany", "UpdateManyAsync",
|
||||
"ReplaceOne", "ReplaceOneAsync",
|
||||
"BulkWrite", "BulkWriteAsync",
|
||||
"ExecuteNonQuery", "ExecuteNonQueryAsync",
|
||||
"SaveChanges", "SaveChangesAsync",
|
||||
"Add", "AddAsync",
|
||||
"Update", "UpdateAsync"
|
||||
};
|
||||
|
||||
foreach (var op in writeOps)
|
||||
{
|
||||
if (string.Equals(name, op, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWithinAocGuardScope(IInvocationOperation invocation)
|
||||
{
|
||||
// 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.ContainingType?.Name.Contains("AocGuard", StringComparison.Ordinal) == true)
|
||||
{
|
||||
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 (param.Type.Name.Contains("AocGuard", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user