Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ control plane, or runtime AI filter.
- `src/Bower.Ocsf`: OCSF normalisation engine and source mappers.
- `src/Bower.Detection`: Sigma-compatible detection rules engine.
- `src/Bower.Pipeline`: declarative telemetry pipeline model, templates and validation.
- `src/Bower.Analytics`: telemetry quality and coverage scoring.
- `schemas`, `policies`, `deploy`, `docs`, `tests`: versioned product assets.

Inspect nearest `AGENTS.md` before editing.
Expand Down
15 changes: 15 additions & 0 deletions Bower.sln
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Detection", "src\Bowe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Pipeline", "src\Bower.Pipeline\Bower.Pipeline.csproj", "{E125F241-5F0D-43FD-8781-092995E1D309}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Analytics", "src\Bower.Analytics\Bower.Analytics.csproj", "{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -281,6 +283,18 @@ Global
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|x64.Build.0 = Release|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|x86.ActiveCfg = Release|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|x86.Build.0 = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|x64.ActiveCfg = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|x64.Build.0 = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|x86.ActiveCfg = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Debug|x86.Build.0 = Debug|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|Any CPU.Build.0 = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x64.ActiveCfg = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x64.Build.0 = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x86.ActiveCfg = Release|Any CPU
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -292,5 +306,6 @@ Global
{F95A8CF7-7C6D-49DF-853D-F845DB65C26F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C63ADD85-CA8A-49DC-9366-30E9426C7062} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E125F241-5F0D-43FD-8781-092995E1D309} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F1BFA9A3-4762-47C4-B97D-578A105A8D6C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
4 changes: 4 additions & 0 deletions src/Bower.Analytics/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Analytics instructions

Quality scoring is deterministic and explainable. Never invent coverage for
unobserved sources. Scores must include component breakdowns and evidence counts.
5 changes: 5 additions & 0 deletions src/Bower.Analytics/Bower.Analytics.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../Bower.Contracts/Bower.Contracts.csproj" />
</ItemGroup>
</Project>
249 changes: 249 additions & 0 deletions src/Bower.Analytics/TelemetryQualityAssessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using Bower.Contracts;

namespace Bower.Analytics;

public sealed record SourceCoverageObservation(
string SourceId,
string SourceType,
bool Healthy,
DateTimeOffset? LastEventAt,
long EventsObserved,
bool Required);

public sealed record FieldCompletenessObservation(
string FieldName,
long PresentCount,
long TotalCount,
bool Required);

public sealed record QualityAssessmentInput(
IReadOnlyList<SourceCoverageObservation> Sources,
IReadOnlyList<FieldCompletenessObservation> Fields,
IReadOnlyList<SecurityEventEnvelope>? SampleEvents = null,
TimeSpan? FreshnessWindow = null);

public sealed record QualityComponentScore(
string Name,
int Score,
int Weight,
string Summary,
IReadOnlyList<string> Evidence);

public sealed record TelemetryQualityReport(
int OverallScore,
string Grade,
IReadOnlyList<QualityComponentScore> Components,
IReadOnlyList<string> Recommendations,
DateTimeOffset AssessedAt);

public static class TelemetryQualityAssessor
{
public static TelemetryQualityReport Assess(
QualityAssessmentInput input,
DateTimeOffset? now = null)
{
ArgumentNullException.ThrowIfNull(input);
DateTimeOffset assessedAt = now ?? DateTimeOffset.UtcNow;
TimeSpan freshnessWindow = input.FreshnessWindow ?? TimeSpan.FromHours(24);

QualityComponentScore coverage = ScoreCoverage(input.Sources, assessedAt, freshnessWindow);
QualityComponentScore completeness = ScoreCompleteness(input.Fields);
QualityComponentScore freshness = ScoreFreshness(input.Sources, assessedAt, freshnessWindow);
QualityComponentScore schema = ScoreSchema(input.SampleEvents ?? []);

QualityComponentScore[] components = [coverage, completeness, freshness, schema];
int totalWeight = components.Sum(item => item.Weight);
int overall = totalWeight == 0
? 0
: (int)Math.Round(
components.Sum(item => item.Score * item.Weight) / (double)totalWeight,
MidpointRounding.AwayFromZero);

List<string> recommendations = [];
foreach (QualityComponentScore component in components.Where(item => item.Score < 70))
{
recommendations.AddRange(component.Evidence.Select(item => $"{component.Name}: {item}"));
}

if (recommendations.Count == 0)
{
recommendations.Add("Telemetry quality is within target thresholds.");
}

return new TelemetryQualityReport(
overall,
Grade(overall),
components,
recommendations,
assessedAt);
}

private static QualityComponentScore ScoreCoverage(
IReadOnlyList<SourceCoverageObservation> sources,
DateTimeOffset now,
TimeSpan freshnessWindow)
{
if (sources.Count == 0)
{
return new QualityComponentScore(
"coverage",
0,
30,
"No sources configured.",
["Register required security sources."]);
}

SourceCoverageObservation[] required = sources.Where(item => item.Required).ToArray();
IReadOnlyList<SourceCoverageObservation> basis = required.Length > 0 ? required : sources;
int covered = basis.Count(item =>
item.Healthy &&
item.EventsObserved > 0 &&
item.LastEventAt is { } last &&
now - last <= freshnessWindow);
int score = (int)Math.Round(100.0 * covered / basis.Count, MidpointRounding.AwayFromZero);
List<string> evidence = basis
.Where(item => !(item.Healthy && item.EventsObserved > 0))
.Select(item => $"Source '{item.SourceId}' ({item.SourceType}) not contributing.")
.Take(5)
.ToList();

return new QualityComponentScore(
"coverage",
score,
30,
$"{covered}/{basis.Count} required sources healthy with recent events.",
evidence);
}

private static QualityComponentScore ScoreCompleteness(
IReadOnlyList<FieldCompletenessObservation> fields)
{
FieldCompletenessObservation[] required = fields
.Where(item => item.Required && item.TotalCount > 0)
.ToArray();
if (required.Length == 0)
{
return new QualityComponentScore(
"completeness",
50,
25,
"No required field observations supplied.",
["Provide field completeness samples for actor, action, target and correlation."]);
}

double average = required.Average(item => 100.0 * item.PresentCount / item.TotalCount);
int score = (int)Math.Round(average, MidpointRounding.AwayFromZero);
List<string> evidence = required
.Where(item => item.PresentCount * 100.0 / item.TotalCount < 80)
.Select(item =>
$"Field '{item.FieldName}' present in {item.PresentCount}/{item.TotalCount} events.")
.Take(5)
.ToList();

return new QualityComponentScore(
"completeness",
score,
25,
"Average required-field fill rate.",
evidence);
}

private static QualityComponentScore ScoreFreshness(
IReadOnlyList<SourceCoverageObservation> sources,
DateTimeOffset now,
TimeSpan freshnessWindow)
{
SourceCoverageObservation[] withEvents = sources
.Where(item => item.LastEventAt is not null)
.ToArray();
if (withEvents.Length == 0)
{
return new QualityComponentScore(
"freshness",
0,
25,
"No source timestamps available.",
["Confirm collectors are emitting heartbeats and events."]);
}

int fresh = withEvents.Count(item => now - item.LastEventAt! <= freshnessWindow);
int score = (int)Math.Round(100.0 * fresh / withEvents.Length, MidpointRounding.AwayFromZero);
List<string> evidence = withEvents
.Where(item => now - item.LastEventAt! > freshnessWindow)
.Select(item => $"Source '{item.SourceId}' last event at {item.LastEventAt:O}.")
.Take(5)
.ToList();

return new QualityComponentScore(
"freshness",
score,
25,
$"{fresh}/{withEvents.Length} sources within freshness window {freshnessWindow}.",
evidence);
}

private static QualityComponentScore ScoreSchema(IReadOnlyList<SecurityEventEnvelope> events)
{
if (events.Count == 0)
{
return new QualityComponentScore(
"schema",
50,
20,
"No sample events supplied for schema quality.",
["Include sample events to score actor/target/correlation completeness."]);
}

int points = 0;
int total = events.Count * 4;
foreach (SecurityEventEnvelope envelope in events)
{
if (!string.IsNullOrWhiteSpace(envelope.Actor?.Username) ||
!string.IsNullOrWhiteSpace(envelope.Actor?.UserId))
{
points++;
}

if (envelope.Target is not null)
{
points++;
}

if (envelope.Request?.CorrelationId is not null || envelope.Request?.TraceId is not null)
{
points++;
}

if (!string.IsNullOrWhiteSpace(envelope.EventAction))
{
points++;
}
}

int score = (int)Math.Round(100.0 * points / total, MidpointRounding.AwayFromZero);
List<string> evidence = [];
if (score < 80)
{
evidence.Add("Increase actor, target, correlation and action population on emitted events.");
}

return new QualityComponentScore(
"schema",
score,
20,
"Semantic field population across sample events.",
evidence);
}

private static string Grade(int score)
{
return score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F"
};
}
}
10 changes: 10 additions & 0 deletions src/Bower.Analytics/packages.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"bower.contracts": {
"type": "Project"
}
}
}
}
1 change: 1 addition & 0 deletions tests/Bower.UnitTests/Bower.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Bower.Pipeline\Bower.Pipeline.csproj" />
<ProjectReference Include="..\..\src\Bower.Analytics\Bower.Analytics.csproj" />
</ItemGroup>
</Project>
55 changes: 55 additions & 0 deletions tests/Bower.UnitTests/TelemetryQualityAssessorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Bower.Analytics;
using Bower.Contracts;

namespace Bower.UnitTests;

public sealed class TelemetryQualityAssessorTests
{
[Fact]
public void Assess_HealthyFleet_ScoresHigh()
{
DateTimeOffset now = DateTimeOffset.Parse("2026-07-28T00:00:00Z", System.Globalization.CultureInfo.InvariantCulture);
QualityAssessmentInput input = new(
[
new SourceCoverageObservation("http", "http-collector", true, now.AddMinutes(-5), 100, true),
new SourceCoverageObservation("sql", "sqlserver", true, now.AddMinutes(-10), 50, true)
],
[
new FieldCompletenessObservation("actor.username", 95, 100, true),
new FieldCompletenessObservation("target.name", 90, 100, true),
new FieldCompletenessObservation("request.correlationId", 88, 100, true)
],
[
new SecurityEventEnvelope
{
SchemaVersion = SecurityEventEnvelope.CurrentSchemaVersion,
EventId = Guid.CreateVersion7().ToString(),
TimeGenerated = now,
EventCategory = SecurityEventCategories.Authentication,
EventType = SecurityEventTypes.AuthenticationFailure,
EventAction = "authentication.attempt",
EventResult = EventResult.Failure,
Application = new ApplicationContext { Name = "app", Environment = "test" },
Actor = new ActorContext { Username = "alice" },
Target = new TargetContext { Type = "account", Name = "alice" },
Request = new RequestContext { CorrelationId = "c1" }
}
]);

TelemetryQualityReport report = TelemetryQualityAssessor.Assess(input, now);

Assert.True(report.OverallScore >= 85);
Assert.True(report.Grade is "A" or "B");
Assert.Equal(4, report.Components.Count);
}

[Fact]
public void Assess_MissingSources_RecommendsCoverage()
{
TelemetryQualityReport report = TelemetryQualityAssessor.Assess(
new QualityAssessmentInput([], []));

Assert.Equal(0, report.Components.Single(item => item.Name == "coverage").Score);
Assert.Contains(report.Recommendations, item => item.Contains("sources", StringComparison.OrdinalIgnoreCase));
}
}
6 changes: 6 additions & 0 deletions tests/Bower.UnitTests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,12 @@
"Bower.Contracts": "[1.0.0, )"
}
},
"bower.analytics": {
"type": "Project",
"dependencies": {
"Bower.Contracts": "[1.0.0, )"
}
},
"bower.contracts": {
"type": "Project"
},
Expand Down