feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Expressions;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Expressions;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
public sealed class TaskPackPlanner
|
||||
{
|
||||
private readonly TaskPackManifestValidator validator;
|
||||
|
||||
public TaskPackPlanner()
|
||||
{
|
||||
validator = new TaskPackManifestValidator();
|
||||
}
|
||||
private static readonly string[] NetworkParameterHints = { "url", "uri", "endpoint", "host", "registry", "mirror", "address" };
|
||||
|
||||
private readonly TaskPackManifestValidator validator;
|
||||
private readonly IEgressPolicy? egressPolicy;
|
||||
|
||||
public TaskPackPlanner(IEgressPolicy? egressPolicy = null)
|
||||
{
|
||||
validator = new TaskPackManifestValidator();
|
||||
this.egressPolicy = egressPolicy;
|
||||
}
|
||||
|
||||
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
|
||||
{
|
||||
@@ -50,14 +57,17 @@ public sealed class TaskPackPlanner
|
||||
|
||||
var context = TaskPackExpressionContext.Create(effectiveInputs, stepTracker, secretTracker);
|
||||
|
||||
var planSteps = new List<TaskPackPlanStep>();
|
||||
var steps = manifest.Spec.Steps;
|
||||
for (var i = 0; i < steps.Count; i++)
|
||||
{
|
||||
var step = steps[i];
|
||||
var planStep = BuildStep(step, context, $"spec.steps[{i}]", errors);
|
||||
planSteps.Add(planStep);
|
||||
}
|
||||
var packName = manifest.Metadata.Name;
|
||||
var packVersion = manifest.Metadata.Version;
|
||||
|
||||
var planSteps = new List<TaskPackPlanStep>();
|
||||
var steps = manifest.Spec.Steps;
|
||||
for (var i = 0; i < steps.Count; i++)
|
||||
{
|
||||
var step = steps[i];
|
||||
var planStep = BuildStep(packName, packVersion, step, context, $"spec.steps[{i}]", errors);
|
||||
planSteps.Add(planStep);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
@@ -70,13 +80,13 @@ public sealed class TaskPackPlanner
|
||||
manifest.Metadata.Description,
|
||||
manifest.Metadata.Tags?.ToList() ?? new List<string>());
|
||||
|
||||
var planApprovals = manifest.Spec.Approvals?
|
||||
.Select(approval => new TaskPackPlanApproval(
|
||||
approval.Id,
|
||||
approval.Grants?.ToList() ?? new List<string>(),
|
||||
approval.ExpiresAfter,
|
||||
approval.ReasonTemplate))
|
||||
.ToList() ?? new List<TaskPackPlanApproval>();
|
||||
var planApprovals = manifest.Spec.Approvals?
|
||||
.Select(approval => new TaskPackPlanApproval(
|
||||
approval.Id,
|
||||
NormalizeGrants(approval.Grants),
|
||||
approval.ExpiresAfter,
|
||||
approval.ReasonTemplate))
|
||||
.ToList() ?? new List<TaskPackPlanApproval>();
|
||||
|
||||
var planSecrets = manifest.Spec.Secrets?
|
||||
.Select(secret => new TaskPackPlanSecret(secret.Name, secret.Scope, secret.Description))
|
||||
@@ -134,11 +144,13 @@ public sealed class TaskPackPlanner
|
||||
return effective;
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildStep(
|
||||
TaskPackStep step,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
private TaskPackPlanStep BuildStep(
|
||||
string packName,
|
||||
string packVersion,
|
||||
TaskPackStep step,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (!TaskPackExpressions.TryEvaluateBoolean(step.When, context, out var enabled, out var whenError))
|
||||
{
|
||||
@@ -146,23 +158,23 @@ public sealed class TaskPackPlanner
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
TaskPackPlanStep planStep;
|
||||
|
||||
if (step.Run is not null)
|
||||
{
|
||||
planStep = BuildRunStep(step, step.Run, context, path, enabled, errors);
|
||||
TaskPackPlanStep planStep;
|
||||
|
||||
if (step.Run is not null)
|
||||
{
|
||||
planStep = BuildRunStep(packName, packVersion, step, step.Run, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Gate is not null)
|
||||
{
|
||||
planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Gate is not null)
|
||||
{
|
||||
planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Parallel is not null)
|
||||
{
|
||||
planStep = BuildParallelStep(step, step.Parallel, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Map is not null)
|
||||
{
|
||||
planStep = BuildMapStep(step, step.Map, context, path, enabled, errors);
|
||||
else if (step.Parallel is not null)
|
||||
{
|
||||
planStep = BuildParallelStep(packName, packVersion, step, step.Parallel, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Map is not null)
|
||||
{
|
||||
planStep = BuildMapStep(packName, packVersion, step, step.Map, context, path, enabled, errors);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -174,28 +186,235 @@ public sealed class TaskPackPlanner
|
||||
return planStep;
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildRunStep(
|
||||
TaskPackStep step,
|
||||
TaskPackRunStep run,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
private TaskPackPlanStep BuildRunStep(
|
||||
string packName,
|
||||
string packVersion,
|
||||
TaskPackStep step,
|
||||
TaskPackRunStep run,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var parameters = ResolveParameters(run.With, context, $"{path}.run", errors);
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
"run",
|
||||
enabled,
|
||||
run.Uses,
|
||||
parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: null);
|
||||
}
|
||||
var parameters = ResolveParameters(run.With, context, $"{path}.run", errors);
|
||||
|
||||
if (egressPolicy?.IsSealed == true)
|
||||
{
|
||||
ValidateRunStepEgress(packName, packVersion, step, run, parameters, path, errors);
|
||||
}
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
"run",
|
||||
enabled,
|
||||
run.Uses,
|
||||
parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: null);
|
||||
}
|
||||
|
||||
private void ValidateRunStepEgress(
|
||||
string packName,
|
||||
string packVersion,
|
||||
TaskPackStep step,
|
||||
TaskPackRunStep run,
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (egressPolicy is null || !egressPolicy.IsSealed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var destinations = new List<Uri>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void AddDestination(Uri uri)
|
||||
{
|
||||
if (seen.Add(uri.ToString()))
|
||||
{
|
||||
destinations.Add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (run.Egress is not null)
|
||||
{
|
||||
for (var i = 0; i < run.Egress.Count; i++)
|
||||
{
|
||||
var entry = run.Egress[i];
|
||||
var entryPath = $"{path}.egress[{i}]";
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseNetworkUri(entry.Url, out var uri))
|
||||
{
|
||||
AddDestination(uri);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var requiresRuntimeNetwork = false;
|
||||
|
||||
if (parameters is not null)
|
||||
{
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
var value = parameter.Value;
|
||||
if (value.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var literal) && TryParseNetworkUri(literal, out var uri))
|
||||
{
|
||||
AddDestination(uri);
|
||||
}
|
||||
else if (value.RequiresRuntimeValue && MightBeNetworkParameter(parameter.Key))
|
||||
{
|
||||
requiresRuntimeNetwork = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (destinations.Count == 0)
|
||||
{
|
||||
if (requiresRuntimeNetwork && (run.Egress is null || run.Egress.Count == 0))
|
||||
{
|
||||
errors.Add(new TaskPackPlanError(path, $"Step '{step.Id}' references runtime network parameters while sealed mode is enabled. Declare explicit run.egress URLs or remove external calls."));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var destination in destinations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new EgressRequest(
|
||||
component: "TaskRunner",
|
||||
destination: destination,
|
||||
intent: $"taskpack:{packName}@{packVersion}:{step.Id}",
|
||||
transport: DetermineTransport(destination),
|
||||
operation: run.Uses);
|
||||
|
||||
egressPolicy.EnsureAllowed(request);
|
||||
}
|
||||
catch (AirGapEgressBlockedException blocked)
|
||||
{
|
||||
var remediation = blocked.Remediation;
|
||||
errors.Add(new TaskPackPlanError(
|
||||
path,
|
||||
$"Step '{step.Id}' attempted to reach '{destination}' in sealed mode and was blocked. Reason: {blocked.Reason}. Remediation: {remediation}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseNetworkUri(string? value, out Uri uri)
|
||||
{
|
||||
uri = default!;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsNetworkScheme(parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsNetworkScheme(Uri uri)
|
||||
=> string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool MightBeNetworkParameter(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var hint in NetworkParameterHints)
|
||||
{
|
||||
if (name.Contains(hint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static EgressTransport DetermineTransport(Uri destination)
|
||||
=> string.Equals(destination.Scheme, "https", StringComparison.OrdinalIgnoreCase)
|
||||
? EgressTransport.Https
|
||||
: string.Equals(destination.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||
? EgressTransport.Http
|
||||
: EgressTransport.Any;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeGrants(IReadOnlyList<string>? grants)
|
||||
{
|
||||
if (grants is null || grants.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new List<string>(grants.Count);
|
||||
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(grant))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var segments = grant
|
||||
.Split('.', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(segment =>
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (trimmed.Length == 1)
|
||||
{
|
||||
return trimmed.ToUpperInvariant();
|
||||
}
|
||||
|
||||
var first = char.ToUpperInvariant(trimmed[0]);
|
||||
var rest = trimmed[1..].ToLowerInvariant();
|
||||
return string.Concat(first, rest);
|
||||
})
|
||||
.Where(segment => segment.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(string.Join('.', segments));
|
||||
}
|
||||
|
||||
return normalized.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildGateStep(
|
||||
TaskPackStep step,
|
||||
@@ -238,20 +457,22 @@ public sealed class TaskPackPlanner
|
||||
Children: null);
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildParallelStep(
|
||||
TaskPackStep step,
|
||||
TaskPackParallelStep parallel,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var children = new List<TaskPackPlanStep>();
|
||||
for (var i = 0; i < parallel.Steps.Count; i++)
|
||||
{
|
||||
var child = BuildStep(parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors);
|
||||
children.Add(child);
|
||||
}
|
||||
private TaskPackPlanStep BuildParallelStep(
|
||||
string packName,
|
||||
string packVersion,
|
||||
TaskPackStep step,
|
||||
TaskPackParallelStep parallel,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var children = new List<TaskPackPlanStep>();
|
||||
for (var i = 0; i < parallel.Steps.Count; i++)
|
||||
{
|
||||
var child = BuildStep(packName, packVersion, parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors);
|
||||
children.Add(child);
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
if (parallel.MaxParallel.HasValue)
|
||||
@@ -274,12 +495,14 @@ public sealed class TaskPackPlanner
|
||||
Children: children);
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildMapStep(
|
||||
TaskPackStep step,
|
||||
TaskPackMapStep map,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
private TaskPackPlanStep BuildMapStep(
|
||||
string packName,
|
||||
string packVersion,
|
||||
TaskPackStep step,
|
||||
TaskPackMapStep map,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
@@ -324,7 +547,7 @@ public sealed class TaskPackPlanner
|
||||
var item = itemsArray[i];
|
||||
var iterationContext = context.WithItem(item);
|
||||
var iterationPath = $"{path}.map.step[{i}]";
|
||||
var templateStep = BuildStep(map.Step, iterationContext, iterationPath, errors);
|
||||
var templateStep = BuildStep(packName, packVersion, map.Step, iterationContext, iterationPath, errors);
|
||||
|
||||
var childId = $"{step.Id}[{i}]::{map.Step.Id}";
|
||||
var iterationParameters = templateStep.Parameters is null
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -150,18 +150,33 @@ public sealed class TaskPackStep
|
||||
public TaskPackMapStep? Map { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackRunStep
|
||||
{
|
||||
[JsonPropertyName("uses")]
|
||||
public required string Uses { get; init; }
|
||||
|
||||
[JsonPropertyName("with")]
|
||||
public IDictionary<string, JsonNode?>? With { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackGateStep
|
||||
{
|
||||
[JsonPropertyName("approval")]
|
||||
public sealed class TaskPackRunStep
|
||||
{
|
||||
[JsonPropertyName("uses")]
|
||||
public required string Uses { get; init; }
|
||||
|
||||
[JsonPropertyName("with")]
|
||||
public IDictionary<string, JsonNode?>? With { get; init; }
|
||||
|
||||
[JsonPropertyName("egress")]
|
||||
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackRunEgress
|
||||
{
|
||||
[JsonPropertyName("url")]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[JsonPropertyName("intent")]
|
||||
public string? Intent { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackGateStep
|
||||
{
|
||||
[JsonPropertyName("approval")]
|
||||
public TaskPackApprovalGate? Approval { get; init; }
|
||||
|
||||
[JsonPropertyName("policy")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
|
||||
@@ -134,10 +135,10 @@ public sealed class TaskPackManifestValidator
|
||||
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, or map."));
|
||||
}
|
||||
|
||||
if (step.Run is not null)
|
||||
{
|
||||
ValidateRunStep(step.Run, $"{path}.run", errors);
|
||||
}
|
||||
if (step.Run is not null)
|
||||
{
|
||||
ValidateRunStep(step.Run, $"{path}.run", errors);
|
||||
}
|
||||
|
||||
if (step.Gate is not null)
|
||||
{
|
||||
@@ -156,13 +157,44 @@ public sealed class TaskPackManifestValidator
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Uses))
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
|
||||
}
|
||||
}
|
||||
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Uses))
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
|
||||
}
|
||||
|
||||
if (run.Egress is not null)
|
||||
{
|
||||
for (var i = 0; i < run.Egress.Count; i++)
|
||||
{
|
||||
var entry = run.Egress[i];
|
||||
var entryPath = $"{path}.egress[{i}]";
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Url))
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
|
||||
}
|
||||
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
|
||||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
|
||||
}
|
||||
|
||||
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
@@ -10,17 +11,18 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher
|
||||
{
|
||||
private readonly string queuePath;
|
||||
private readonly string archivePath;
|
||||
private readonly TaskPackManifestLoader manifestLoader = new();
|
||||
private readonly TaskPackPlanner planner = new();
|
||||
private readonly TaskPackManifestLoader manifestLoader = new();
|
||||
private readonly TaskPackPlanner planner;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public FilesystemPackRunDispatcher(string queuePath, string archivePath)
|
||||
{
|
||||
this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath));
|
||||
this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath));
|
||||
Directory.CreateDirectory(queuePath);
|
||||
Directory.CreateDirectory(archivePath);
|
||||
}
|
||||
public FilesystemPackRunDispatcher(string queuePath, string archivePath, IEgressPolicy? egressPolicy = null)
|
||||
{
|
||||
this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath));
|
||||
this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath));
|
||||
planner = new TaskPackPlanner(egressPolicy);
|
||||
Directory.CreateDirectory(queuePath);
|
||||
Directory.CreateDirectory(archivePath);
|
||||
}
|
||||
|
||||
public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -34,8 +36,12 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher
|
||||
|
||||
try
|
||||
{
|
||||
var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions) ?? continue;
|
||||
var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions);
|
||||
if (job is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifestPath = ResolvePath(queuePath, job.ManifestPath);
|
||||
var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath);
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class FilesystemPackRunDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryDequeueAsync_BlocksJob_WhenEgressPolicyDeniesDestination()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var queuePath = Path.Combine(root, "queue");
|
||||
var archivePath = Path.Combine(root, "archive");
|
||||
Directory.CreateDirectory(queuePath);
|
||||
Directory.CreateDirectory(archivePath);
|
||||
|
||||
var manifestPath = Path.Combine(queuePath, "manifest.yaml");
|
||||
await File.WriteAllTextAsync(manifestPath, TestManifests.EgressBlocked, cancellationToken);
|
||||
|
||||
var jobEnvelope = new
|
||||
{
|
||||
RunId = "run-egress-blocked",
|
||||
ManifestPath = Path.GetFileName(manifestPath),
|
||||
InputsPath = (string?)null,
|
||||
RequestedAt = (DateTimeOffset?)null
|
||||
};
|
||||
|
||||
var jobPath = Path.Combine(queuePath, "job.json");
|
||||
await File.WriteAllTextAsync(jobPath, JsonSerializer.Serialize(jobEnvelope), cancellationToken);
|
||||
|
||||
var policy = new EgressPolicy(new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowLoopback = false,
|
||||
AllowPrivateNetworks = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var dispatcher = new FilesystemPackRunDispatcher(queuePath, archivePath, policy);
|
||||
var result = await dispatcher.TryDequeueAsync(cancellationToken);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.False(File.Exists(jobPath));
|
||||
Assert.True(File.Exists(jobPath + ".failed"));
|
||||
Assert.Empty(Directory.GetFiles(archivePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; ignore failures to avoid masking test results.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,8 @@
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
@@ -165,13 +167,62 @@ public sealed class TaskPackPlannerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WhenRequiredInputMissing_ReturnsError()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.RequiredInput);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Path == "inputs.sbomBundle");
|
||||
}
|
||||
}
|
||||
public void Plan_WhenRequiredInputMissing_ReturnsError()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.RequiredInput);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Path == "inputs.sbomBundle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_SealedMode_AllowsDeclaredEgress()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.EgressAllowed);
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed
|
||||
};
|
||||
options.AddAllowRule("mirror.internal", 443, EgressTransport.Https);
|
||||
|
||||
var planner = new TaskPackPlanner(new EgressPolicy(options));
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_SealedMode_BlocksUndeclaredEgress()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.EgressBlocked);
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed
|
||||
};
|
||||
var planner = new TaskPackPlanner(new EgressPolicy(options));
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_SealedMode_RuntimeUrlWithoutDeclaration_ReturnsError()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.EgressRuntime);
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed
|
||||
};
|
||||
var planner = new TaskPackPlanner(new EgressPolicy(options));
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Path.StartsWith("spec.steps[0]", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,23 +143,74 @@ spec:
|
||||
expression: "{{ steps.generate.outputs.evidence }}"
|
||||
""";
|
||||
|
||||
public const string PolicyGate = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: policy-gate-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: prepare
|
||||
run:
|
||||
uses: builtin:prepare
|
||||
- id: policy-check
|
||||
gate:
|
||||
policy:
|
||||
policy: security-hold
|
||||
parameters:
|
||||
threshold: high
|
||||
evidenceRef: "{{ steps.prepare.outputs.evidence }}"
|
||||
""";
|
||||
}
|
||||
public const string PolicyGate = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: policy-gate-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: prepare
|
||||
run:
|
||||
uses: builtin:prepare
|
||||
- id: policy-check
|
||||
gate:
|
||||
policy:
|
||||
policy: security-hold
|
||||
parameters:
|
||||
threshold: high
|
||||
evidenceRef: "{{ steps.prepare.outputs.evidence }}"
|
||||
""";
|
||||
|
||||
public const string EgressAllowed = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: egress-allowed
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
url: https://mirror.internal/api/status
|
||||
egress:
|
||||
- url: https://mirror.internal/api/status
|
||||
""";
|
||||
|
||||
public const string EgressBlocked = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: egress-blocked
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
url: https://example.com/api/status
|
||||
""";
|
||||
|
||||
public const string EgressRuntime = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: egress-runtime
|
||||
version: 1.0.0
|
||||
spec:
|
||||
inputs:
|
||||
- name: targetUrl
|
||||
type: string
|
||||
required: false
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
url: "{{ inputs.targetUrl }}"
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.Worker.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.Worker.Services;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.Configure<PackRunWorkerOptions>(builder.Configuration.GetSection("Worker"));
|
||||
builder.Services.Configure<NotificationOptions>(builder.Configuration.GetSection("Notifications"));
|
||||
builder.Services.AddHttpClient("taskrunner-notifications");
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
builder.Services.Configure<PackRunWorkerOptions>(builder.Configuration.GetSection("Worker"));
|
||||
builder.Services.Configure<NotificationOptions>(builder.Configuration.GetSection("Notifications"));
|
||||
builder.Services.AddHttpClient("taskrunner-notifications");
|
||||
|
||||
builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
|
||||
{
|
||||
@@ -15,11 +17,12 @@ builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
|
||||
return new FilePackRunApprovalStore(options.Value.ApprovalStorePath);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IPackRunJobDispatcher>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
|
||||
return new FilesystemPackRunDispatcher(options.Value.QueuePath, options.Value.ArchivePath);
|
||||
});
|
||||
builder.Services.AddSingleton<IPackRunJobDispatcher>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
|
||||
var egressPolicy = sp.GetRequiredService<IEgressPolicy>();
|
||||
return new FilesystemPackRunDispatcher(options.Value.QueuePath, options.Value.ArchivePath, egressPolicy);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IPackRunNotificationPublisher>(sp =>
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| TASKRUN-AIRGAP-56-001 | TODO | Task Runner Guild, AirGap Policy Guild | AIRGAP-POL-56-001, TASKRUN-OBS-50-001 | Enforce plan-time validation rejecting steps with non-allowlisted network calls in sealed mode and surface remediation errors. | Planner blocks disallowed steps; error contains remediation; tests cover sealed/unsealed behavior. |
|
||||
| TASKRUN-AIRGAP-56-001 | DOING (2025-11-03) | Task Runner Guild, AirGap Policy Guild | AIRGAP-POL-56-001, TASKRUN-OBS-50-001 | Enforce plan-time validation rejecting steps with non-allowlisted network calls in sealed mode and surface remediation errors. | Planner blocks disallowed steps; error contains remediation; tests cover sealed/unsealed behavior. |
|
||||
| TASKRUN-AIRGAP-56-002 | TODO | Task Runner Guild, AirGap Importer Guild | TASKRUN-AIRGAP-56-001, AIRGAP-IMP-57-002 | Add helper steps for bundle ingestion (checksum verification, staging to object store) with deterministic outputs. | Helper steps succeed deterministically; integration tests import sample bundle. |
|
||||
| TASKRUN-AIRGAP-57-001 | TODO | Task Runner Guild, AirGap Controller Guild | TASKRUN-AIRGAP-56-001, AIRGAP-CTL-56-002 | Refuse to execute plans when environment sealed=false but declared sealed install; emit advisory timeline events. | Mismatch detection works; timeline + telemetry record violation; docs updated. |
|
||||
| TASKRUN-AIRGAP-58-001 | TODO | Task Runner Guild, Evidence Locker Guild | TASKRUN-OBS-53-001, EVID-OBS-55-001 | Capture bundle import job transcripts, hashed inputs, and outputs into portable evidence bundles. | Evidence recorded; manifests deterministic; timeline references created. |
|
||||
|
||||
Reference in New Issue
Block a user