feat: Implement vulnerability token signing and verification utilities
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:
master
2025-11-03 10:02:29 +02:00
parent bf2bf4b395
commit b1e78fe412
215 changed files with 19441 additions and 12185 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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")]

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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>

View File

@@ -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.
}
}
}
}

View File

@@ -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"/>

View File

@@ -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));
}
}

View File

@@ -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 }}"
""";
}

View File

@@ -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 =>
{

View File

@@ -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. |