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 @@ -24,6 +24,7 @@ control plane, or runtime AI filter.
- `src/Bower.Source.Aws`: AWS security telemetry parsers (CloudTrail, GuardDuty, Security Hub, CloudWatch).
- `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.
- `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 @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Ocsf", "src\Bower.Ocs
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Detection", "src\Bower.Detection\Bower.Detection.csproj", "{C63ADD85-CA8A-49DC-9366-30E9426C7062}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Pipeline", "src\Bower.Pipeline\Bower.Pipeline.csproj", "{E125F241-5F0D-43FD-8781-092995E1D309}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -267,6 +269,18 @@ Global
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x64.Build.0 = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x86.ActiveCfg = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x86.Build.0 = Release|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|x64.ActiveCfg = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|x64.Build.0 = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|x86.ActiveCfg = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Debug|x86.Build.0 = Debug|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|Any CPU.Build.0 = Release|Any CPU
{E125F241-5F0D-43FD-8781-092995E1D309}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -277,5 +291,6 @@ Global
{EA3A7CAF-51CE-4A81-A808-2239893A5332} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions src/Bower.Cli/Bower.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
<ProjectReference Include="../Bower.Contracts/Bower.Contracts.csproj" />
<ProjectReference Include="../Bower.Persistence/Bower.Persistence.csproj" />
<ProjectReference Include="../Bower.PolicyEngine/Bower.PolicyEngine.csproj" />
<ProjectReference Include="..\Bower.Pipeline\Bower.Pipeline.csproj" />
</ItemGroup>
</Project>
40 changes: 40 additions & 0 deletions src/Bower.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using Bower.Contracts;
using Bower.Persistence;
using Bower.Pipeline;
using Bower.PolicyEngine;

return await RunAsync(args);
Expand All @@ -14,6 +15,8 @@ static async Task<int> RunAsync(string[] args)
{
["version"] => WriteVersion(),
["validate", .. string[] rest] => Validate(rest),
["pipeline", "validate", .. string[] rest] => ValidatePipeline(rest),
["pipeline", "template", .. string[] rest] => PipelineTemplate(rest),
["queue", "inspect", .. string[] rest] => await InspectQueueAsync(rest),
["test", "emit", .. string[] rest] => await EmitCanaryAsync(rest),
["developer", "init", .. string[] rest] => DeveloperInit(rest),
Expand Down Expand Up @@ -55,6 +58,41 @@ static int Validate(string[] args)
return 0;
}

static int ValidatePipeline(string[] args)
{
string? path = GetOption(args, "--file") ?? args.FirstOrDefault(item => !item.StartsWith('-'));
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return Fail("Provide an existing pipeline file with --file PATH.");
}

TelemetryPipeline pipeline = PipelineDocument.ParseYaml(File.ReadAllText(path));
PipelineValidationResult validation = PipelineValidator.Validate(pipeline);
PipelinePerformanceEstimate estimate = PipelineValidator.Estimate(pipeline);
Console.WriteLine(
JsonSerializer.Serialize(
new
{
valid = validation.IsValid,
pipeline.Id,
pipeline.Name,
pipeline.Version,
order = validation.TopologicalOrder,
estimate,
issues = validation.Issues
},
new JsonSerializerOptions { WriteIndented = true }));
return validation.IsValid ? 0 : 2;
}

static int PipelineTemplate(string[] args)
{
string templateId = GetOption(args, "--id") ?? args.FirstOrDefault(item => !item.StartsWith('-')) ?? "sentinel-app";
TelemetryPipeline pipeline = PipelineValidator.CreateTemplate(templateId);
Console.WriteLine(PipelineDocument.ToYaml(pipeline));
return 0;
}

static async Task<int> InspectQueueAsync(string[] args)
{
string path = GetOption(args, "--database")
Expand Down Expand Up @@ -191,6 +229,8 @@ Bower security telemetry

Commands:
bower validate [--policy-directory PATH]
bower pipeline validate --file PATH
bower pipeline template [--id sentinel-app|aws-security]
bower queue inspect [--database PATH]
bower test emit [--endpoint URL]
bower developer init [--path PATH]
Expand Down
6 changes: 6 additions & 0 deletions src/Bower.Cli/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@
"SQLitePCLRaw.bundle_e_sqlite3": "[3.0.4, )"
}
},
"bower.pipeline": {
"type": "Project",
"dependencies": {
"YamlDotNet": "[18.1.0, )"
}
},
"bower.policyengine": {
"type": "Project",
"dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions src/Bower.Management.Api/Bower.Management.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Bower.Pipeline\Bower.Pipeline.csproj" />
</ItemGroup>
</Project>
28 changes: 28 additions & 0 deletions src/Bower.Management.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
using Bower.Management.Api;
using Bower.Pipeline;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -229,6 +230,33 @@ await store.GetAsync(id, cancellationToken) is { } record
})
.RequireAuthorization("Collector");

api.MapGet(
"/pipelines/templates",
() => Results.Ok(
new[]
{
PipelineValidator.CreateTemplate("sentinel-app"),
PipelineValidator.CreateTemplate("aws-security")
}))
.RequireAuthorization("View");

api.MapPost(
"/pipelines/validate",
(TelemetryPipeline pipeline) =>
{
PipelineValidationResult validation = PipelineValidator.Validate(pipeline);
PipelinePerformanceEstimate estimate = PipelineValidator.Estimate(pipeline);
return Results.Ok(
new
{
validation.IsValid,
validation.Issues,
validation.TopologicalOrder,
estimate
});
})
.RequireAuthorization("Operate");

api.MapGet(
"/approvals",
(ManagementStore store, CancellationToken cancellationToken) =>
Expand Down
12 changes: 12 additions & 0 deletions src/Bower.Management.Api/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"bower.pipeline": {
"type": "Project",
"dependencies": {
"YamlDotNet": "[18.1.0, )"
}
},
"YamlDotNet": {
"type": "CentralTransitive",
"requested": "[18.1.0, )",
"resolved": "18.1.0",
"contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw=="
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/Bower.Pipeline/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Pipeline builder instructions

Pipeline documents are declarative graphs only. Validation must reject cycles,
unknown node kinds, unbounded fan-out and secret material in node config.
Version every pipeline document and keep import/export pure.
5 changes: 5 additions & 0 deletions src/Bower.Pipeline/Bower.Pipeline.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>
113 changes: 113 additions & 0 deletions src/Bower.Pipeline/PipelineDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Text;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace Bower.Pipeline;

public static class PipelineDocument
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();

private static readonly ISerializer Serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.Build();

public static TelemetryPipeline ParseYaml(string yaml)
{
ArgumentException.ThrowIfNullOrWhiteSpace(yaml);
if (Encoding.UTF8.GetByteCount(yaml) > 512 * 1024)
{
throw new InvalidDataException("Pipeline document exceeds 512 KiB.");
}

PipelineDto dto = Deserializer.Deserialize<PipelineDto>(yaml)
?? throw new InvalidDataException("Pipeline document was empty.");
return dto.ToModel();
}

public static string ToYaml(TelemetryPipeline pipeline)
{
ArgumentNullException.ThrowIfNull(pipeline);
return Serializer.Serialize(PipelineDto.FromModel(pipeline));
}

private sealed class PipelineDto
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Version { get; set; }
public string? Description { get; set; }
public List<NodeDto>? Nodes { get; set; }
public List<EdgeDto>? Edges { get; set; }
public List<string>? Tags { get; set; }

public TelemetryPipeline ToModel()
{
return new TelemetryPipeline(
Id ?? string.Empty,
Name ?? string.Empty,
Version ?? string.Empty,
Description ?? string.Empty,
(Nodes ?? []).Select(node => node.ToModel()).ToArray(),
(Edges ?? []).Select(edge => edge.ToModel()).ToArray(),
Tags);
}

public static PipelineDto FromModel(TelemetryPipeline pipeline)
{
return new PipelineDto
{
Id = pipeline.Id,
Name = pipeline.Name,
Version = pipeline.Version,
Description = pipeline.Description,
Nodes = pipeline.Nodes.Select(NodeDto.FromModel).ToList(),
Edges = pipeline.Edges.Select(EdgeDto.FromModel).ToList(),
Tags = pipeline.Tags?.ToList()
};
}
}

private sealed class NodeDto
{
public string? Id { get; set; }
public string? Kind { get; set; }
public string? Type { get; set; }
public Dictionary<string, string>? Config { get; set; }

public PipelineNode ToModel()
{
if (!Enum.TryParse(Kind, ignoreCase: true, out PipelineNodeKind kind))
{
throw new InvalidDataException($"Unknown node kind '{Kind}'.");
}

return new PipelineNode(Id ?? string.Empty, kind, Type ?? string.Empty, Config);
}

public static NodeDto FromModel(PipelineNode node)
{
return new NodeDto
{
Id = node.Id,
Kind = node.Kind.ToString(),
Type = node.Type,
Config = node.Config is null ? null : new Dictionary<string, string>(node.Config)
};
}
}

private sealed class EdgeDto
{
public string? From { get; set; }
public string? To { get; set; }

public PipelineEdge ToModel() => new(From ?? string.Empty, To ?? string.Empty);

public static EdgeDto FromModel(PipelineEdge edge) => new() { From = edge.From, To = edge.To };
}
}
47 changes: 47 additions & 0 deletions src/Bower.Pipeline/PipelineModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Bower.Pipeline;

public enum PipelineNodeKind
{
Source,
Filter,
Redact,
Normalise,
Detect,
Enrich,
Output
}

public sealed record PipelineNode(
string Id,
PipelineNodeKind Kind,
string Type,
IReadOnlyDictionary<string, string>? Config = null);

public sealed record PipelineEdge(string From, string To);

public sealed record TelemetryPipeline(
string Id,
string Name,
string Version,
string Description,
IReadOnlyList<PipelineNode> Nodes,
IReadOnlyList<PipelineEdge> Edges,
IReadOnlyList<string>? Tags = null);

public sealed record PipelineValidationIssue(
string Code,
string Message,
string? NodeId = null);

public sealed record PipelineValidationResult(
bool IsValid,
IReadOnlyList<PipelineValidationIssue> Issues,
IReadOnlyList<string> TopologicalOrder,
int EstimatedStageCount);

public sealed record PipelinePerformanceEstimate(
int NodeCount,
int EdgeCount,
int SourceCount,
int OutputCount,
int MaxDepth);
Loading