feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -183,4 +183,158 @@ public class NodeCallGraphExtractorTests
|
||||
Assert.Equal("/users/:id", ep.Route);
|
||||
Assert.Equal("GET", ep.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesSinks()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "test",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "js:test/handler.processRequest",
|
||||
"package": "test",
|
||||
"name": "processRequest"
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"entrypoints": [],
|
||||
"sinks": [
|
||||
{
|
||||
"caller": "js:test/handler.processRequest",
|
||||
"category": "command_injection",
|
||||
"method": "child_process.exec",
|
||||
"site": {
|
||||
"file": "handler.js",
|
||||
"line": 42,
|
||||
"column": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Sinks);
|
||||
var sink = result.Sinks[0];
|
||||
Assert.Equal("js:test/handler.processRequest", sink.Caller);
|
||||
Assert.Equal("command_injection", sink.Category);
|
||||
Assert.Equal("child_process.exec", sink.Method);
|
||||
Assert.NotNull(sink.Site);
|
||||
Assert.Equal("handler.js", sink.Site.File);
|
||||
Assert.Equal(42, sink.Site.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesMultipleSinkCategories()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "vulnerable-app",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"entrypoints": [],
|
||||
"sinks": [
|
||||
{
|
||||
"caller": "js:vulnerable-app/db.query",
|
||||
"category": "sql_injection",
|
||||
"method": "connection.query"
|
||||
},
|
||||
{
|
||||
"caller": "js:vulnerable-app/api.fetch",
|
||||
"category": "ssrf",
|
||||
"method": "fetch"
|
||||
},
|
||||
{
|
||||
"caller": "js:vulnerable-app/file.write",
|
||||
"category": "file_write",
|
||||
"method": "fs.writeFileSync"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Sinks.Count);
|
||||
Assert.Contains(result.Sinks, s => s.Category == "sql_injection");
|
||||
Assert.Contains(result.Sinks, s => s.Category == "ssrf");
|
||||
Assert.Contains(result.Sinks, s => s.Category == "file_write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesEmptySinks()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "safe-app",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"entrypoints": [],
|
||||
"sinks": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Sinks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesMissingSinks()
|
||||
{
|
||||
// Arrange - sinks field omitted entirely
|
||||
var json = """
|
||||
{
|
||||
"module": "legacy-app",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"entrypoints": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert - should default to empty list
|
||||
Assert.Empty(result.Sinks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesSinkWithoutSite()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "test",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"entrypoints": [],
|
||||
"sinks": [
|
||||
{
|
||||
"caller": "js:test/func",
|
||||
"category": "deserialization",
|
||||
"method": "eval"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Sinks);
|
||||
Assert.Null(result.Sinks[0].Site);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
|
||||
|
||||
public class AssumptionCollectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Record_AddsAssumption()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
AssumptionSource.StaticAnalysis,
|
||||
ConfidenceLevel.High);
|
||||
|
||||
var result = collector.Build();
|
||||
|
||||
result.Assumptions.Should().HaveCount(1);
|
||||
result.Assumptions[0].Key.Should().Be("-fstack-protector");
|
||||
result.Assumptions[0].AssumedValue.Should().Be("enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_KeepsHigherConfidence()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"unknown",
|
||||
AssumptionSource.Default,
|
||||
ConfidenceLevel.Low);
|
||||
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
AssumptionSource.StaticAnalysis,
|
||||
ConfidenceLevel.High);
|
||||
|
||||
var result = collector.Build();
|
||||
|
||||
result.Assumptions.Should().HaveCount(1);
|
||||
result.Assumptions[0].AssumedValue.Should().Be("enabled");
|
||||
result.Assumptions[0].Confidence.Should().Be(ConfidenceLevel.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordObservation_UpdatesExisting()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
|
||||
collector.Record(
|
||||
AssumptionCategory.RuntimeConfig,
|
||||
"DEBUG_MODE",
|
||||
"false",
|
||||
AssumptionSource.Default,
|
||||
ConfidenceLevel.Low);
|
||||
|
||||
collector.RecordObservation(
|
||||
AssumptionCategory.RuntimeConfig,
|
||||
"DEBUG_MODE",
|
||||
"true");
|
||||
|
||||
var result = collector.Build();
|
||||
|
||||
result.Assumptions.Should().HaveCount(1);
|
||||
result.Assumptions[0].AssumedValue.Should().Be("false");
|
||||
result.Assumptions[0].ObservedValue.Should().Be("true");
|
||||
result.Assumptions[0].Confidence.Should().Be(ConfidenceLevel.Verified);
|
||||
result.Assumptions[0].IsContradicted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordObservation_CreatesNewWhenNotExisting()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
|
||||
collector.RecordObservation(
|
||||
AssumptionCategory.NetworkExposure,
|
||||
"PORT_8080",
|
||||
"open");
|
||||
|
||||
var result = collector.Build();
|
||||
|
||||
result.Assumptions.Should().HaveCount(1);
|
||||
result.Assumptions[0].AssumedValue.Should().Be("open");
|
||||
result.Assumptions[0].ObservedValue.Should().Be("open");
|
||||
result.Assumptions[0].IsValidated.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsContextId()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"flag",
|
||||
"value",
|
||||
AssumptionSource.Default);
|
||||
|
||||
var result = collector.Build("finding-123");
|
||||
|
||||
result.ContextId.Should().Be("finding-123");
|
||||
result.Id.Should().NotBeNullOrEmpty();
|
||||
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllAssumptions()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"flag1",
|
||||
"value",
|
||||
AssumptionSource.Default);
|
||||
collector.Record(
|
||||
AssumptionCategory.RuntimeConfig,
|
||||
"config1",
|
||||
"value",
|
||||
AssumptionSource.Default);
|
||||
|
||||
collector.Clear();
|
||||
var result = collector.Build();
|
||||
|
||||
result.Assumptions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesUniqueIds()
|
||||
{
|
||||
var collector = new AssumptionCollector();
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"flag",
|
||||
"value",
|
||||
AssumptionSource.Default);
|
||||
|
||||
var result1 = collector.Build();
|
||||
collector.Clear();
|
||||
collector.Record(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"flag",
|
||||
"value",
|
||||
AssumptionSource.Default);
|
||||
var result2 = collector.Build();
|
||||
|
||||
result1.Id.Should().NotBe(result2.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
|
||||
|
||||
public class AssumptionSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void AssumptionSet_Empty_HasLowConfidence()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
set.OverallConfidence.Should().Be(ConfidenceLevel.Low);
|
||||
set.ValidatedCount.Should().Be(0);
|
||||
set.ContradictedCount.Should().Be(0);
|
||||
set.HasContradictions.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_OverallConfidence_ReturnsMinimum()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
|
||||
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low),
|
||||
new Assumption(AssumptionCategory.FeatureGate, "gate1", "value", null, AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
set.OverallConfidence.Should().Be(ConfidenceLevel.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_GetByCategory_ReturnsMatchingAssumptions()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
|
||||
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
|
||||
]
|
||||
};
|
||||
|
||||
set.GetByCategory(AssumptionCategory.CompilerFlag).Should().HaveCount(2);
|
||||
set.GetByCategory(AssumptionCategory.RuntimeConfig).Should().HaveCount(1);
|
||||
set.GetByCategory(AssumptionCategory.FeatureGate).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_Get_ReturnsSpecificAssumption()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "-fstack-protector", "enabled", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High)
|
||||
]
|
||||
};
|
||||
|
||||
var result = set.Get(AssumptionCategory.CompilerFlag, "-fstack-protector");
|
||||
result.Should().NotBeNull();
|
||||
result!.AssumedValue.Should().Be("enabled");
|
||||
|
||||
set.Get(AssumptionCategory.CompilerFlag, "nonexistent").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_ValidationRatio_CalculatedCorrectly()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "enabled", "enabled", AssumptionSource.StaticAnalysis, ConfidenceLevel.Verified),
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "enabled", "disabled", AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
|
||||
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
|
||||
]
|
||||
};
|
||||
|
||||
set.ValidatedCount.Should().Be(1);
|
||||
set.ContradictedCount.Should().Be(1);
|
||||
set.HasContradictions.Should().BeTrue();
|
||||
set.ValidationRatio.Should().Be(0.5); // 1 validated out of 2 with observations
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_WithAssumption_AddsNew()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var newAssumption = new Assumption(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"new-flag",
|
||||
"value",
|
||||
null,
|
||||
AssumptionSource.Default,
|
||||
ConfidenceLevel.Low);
|
||||
|
||||
var updated = set.WithAssumption(newAssumption);
|
||||
|
||||
set.Assumptions.Should().BeEmpty();
|
||||
updated.Assumptions.Should().HaveCount(1);
|
||||
updated.Assumptions[0].Key.Should().Be("new-flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssumptionSet_WithObservation_UpdatesExisting()
|
||||
{
|
||||
var set = new AssumptionSet
|
||||
{
|
||||
Id = "test-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "-fstack-protector", "enabled", null, AssumptionSource.Default, ConfidenceLevel.Low)
|
||||
]
|
||||
};
|
||||
|
||||
var updated = set.WithObservation(AssumptionCategory.CompilerFlag, "-fstack-protector", "disabled");
|
||||
|
||||
updated.Assumptions[0].ObservedValue.Should().Be("disabled");
|
||||
updated.Assumptions[0].IsContradicted.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
|
||||
|
||||
public class AssumptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Assumption_IsValidated_ReturnsTrueWhenValuesMatch()
|
||||
{
|
||||
var assumption = new Assumption(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
"enabled",
|
||||
AssumptionSource.StaticAnalysis,
|
||||
ConfidenceLevel.High);
|
||||
|
||||
assumption.IsValidated.Should().BeTrue();
|
||||
assumption.IsContradicted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assumption_IsContradicted_ReturnsTrueWhenValuesDiffer()
|
||||
{
|
||||
var assumption = new Assumption(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
"disabled",
|
||||
AssumptionSource.StaticAnalysis,
|
||||
ConfidenceLevel.High);
|
||||
|
||||
assumption.IsValidated.Should().BeFalse();
|
||||
assumption.IsContradicted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assumption_NoObservedValue_NeitherValidatedNorContradicted()
|
||||
{
|
||||
var assumption = new Assumption(
|
||||
AssumptionCategory.RuntimeConfig,
|
||||
"DEBUG_MODE",
|
||||
"false",
|
||||
null,
|
||||
AssumptionSource.Default,
|
||||
ConfidenceLevel.Low);
|
||||
|
||||
assumption.IsValidated.Should().BeFalse();
|
||||
assumption.IsContradicted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assumption_CaseInsensitiveComparison()
|
||||
{
|
||||
var assumption = new Assumption(
|
||||
AssumptionCategory.FeatureGate,
|
||||
"FEATURE_ENABLED",
|
||||
"TRUE",
|
||||
"true",
|
||||
AssumptionSource.RuntimeObservation,
|
||||
ConfidenceLevel.Verified);
|
||||
|
||||
assumption.IsValidated.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AssumptionCategory.CompilerFlag)]
|
||||
[InlineData(AssumptionCategory.RuntimeConfig)]
|
||||
[InlineData(AssumptionCategory.FeatureGate)]
|
||||
[InlineData(AssumptionCategory.LoaderBehavior)]
|
||||
[InlineData(AssumptionCategory.NetworkExposure)]
|
||||
[InlineData(AssumptionCategory.ProcessPrivilege)]
|
||||
[InlineData(AssumptionCategory.MemoryProtection)]
|
||||
[InlineData(AssumptionCategory.SyscallAvailability)]
|
||||
public void AssumptionCategory_AllValuesAreValid(AssumptionCategory category)
|
||||
{
|
||||
var assumption = new Assumption(
|
||||
category,
|
||||
"test-key",
|
||||
"test-value",
|
||||
null,
|
||||
AssumptionSource.Default,
|
||||
ConfidenceLevel.Low);
|
||||
|
||||
assumption.Category.Should().Be(category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Explainability.Confidence;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Confidence;
|
||||
|
||||
public class EvidenceDensityScorerTests
|
||||
{
|
||||
private readonly EvidenceDensityScorer _scorer = new();
|
||||
|
||||
[Fact]
|
||||
public void Calculate_EmptyFactors_ReturnsLowConfidence()
|
||||
{
|
||||
var factors = new EvidenceFactors { SourceCount = 0 };
|
||||
|
||||
var result = _scorer.Calculate(factors);
|
||||
|
||||
result.Score.Should().Be(0.0);
|
||||
result.Level.Should().Be(ConfidenceLevel.Low);
|
||||
result.ImprovementRecommendations.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AllFactorsPresent_ReturnsHighConfidence()
|
||||
{
|
||||
var assumptions = new AssumptionSet
|
||||
{
|
||||
Id = "test",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag", "value", "value", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
var falsifiability = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "test",
|
||||
FindingId = "finding",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Criteria =
|
||||
[
|
||||
new FalsificationCriterion(FalsificationType.PackageNotPresent, "desc", null, null, CriterionStatus.NotSatisfied)
|
||||
]
|
||||
};
|
||||
|
||||
var factors = new EvidenceFactors
|
||||
{
|
||||
Assumptions = assumptions,
|
||||
Falsifiability = falsifiability,
|
||||
HasStaticReachability = true,
|
||||
HasRuntimeObservations = true,
|
||||
HasSbomLineage = true,
|
||||
SourceCount = 3,
|
||||
HasVexAssessment = true,
|
||||
HasKnownExploit = true
|
||||
};
|
||||
|
||||
var result = _scorer.Calculate(factors);
|
||||
|
||||
result.Score.Should().BeGreaterThan(0.75);
|
||||
result.Level.Should().Be(ConfidenceLevel.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FactorBreakdown_ContainsAllFactors()
|
||||
{
|
||||
var factors = new EvidenceFactors
|
||||
{
|
||||
HasStaticReachability = true
|
||||
};
|
||||
|
||||
var result = _scorer.Calculate(factors);
|
||||
|
||||
result.FactorBreakdown.Should().ContainKey("assumption_validation");
|
||||
result.FactorBreakdown.Should().ContainKey("falsifiability_evaluation");
|
||||
result.FactorBreakdown.Should().ContainKey("static_reachability");
|
||||
result.FactorBreakdown.Should().ContainKey("runtime_observations");
|
||||
result.FactorBreakdown.Should().ContainKey("sbom_lineage");
|
||||
result.FactorBreakdown.Should().ContainKey("multiple_sources");
|
||||
result.FactorBreakdown.Should().ContainKey("vex_assessment");
|
||||
result.FactorBreakdown.Should().ContainKey("known_exploit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_StaticReachabilityOnly_AddsThatFactor()
|
||||
{
|
||||
var factors = new EvidenceFactors
|
||||
{
|
||||
HasStaticReachability = true
|
||||
};
|
||||
|
||||
var result = _scorer.Calculate(factors);
|
||||
|
||||
result.FactorBreakdown["static_reachability"].Should().BeGreaterThan(0);
|
||||
result.Score.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MultipleSourcesScalesCorrectly()
|
||||
{
|
||||
var factors1 = new EvidenceFactors { SourceCount = 1 };
|
||||
var factors2 = new EvidenceFactors { SourceCount = 2 };
|
||||
var factors3 = new EvidenceFactors { SourceCount = 3 };
|
||||
var factors4 = new EvidenceFactors { SourceCount = 10 }; // Capped at 3
|
||||
|
||||
var result1 = _scorer.Calculate(factors1);
|
||||
var result2 = _scorer.Calculate(factors2);
|
||||
var result3 = _scorer.Calculate(factors3);
|
||||
var result4 = _scorer.Calculate(factors4);
|
||||
|
||||
result2.FactorBreakdown["multiple_sources"].Should().BeGreaterThan(result1.FactorBreakdown["multiple_sources"]);
|
||||
result3.FactorBreakdown["multiple_sources"].Should().BeGreaterThan(result2.FactorBreakdown["multiple_sources"]);
|
||||
result4.FactorBreakdown["multiple_sources"].Should().Be(result3.FactorBreakdown["multiple_sources"]); // Capped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AssumptionValidationRatio_AffectsScore()
|
||||
{
|
||||
var halfValidated = new AssumptionSet
|
||||
{
|
||||
Id = "test",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "a", "a", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified),
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "b", "c", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
var fullyValidated = new AssumptionSet
|
||||
{
|
||||
Id = "test",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "a", "a", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified),
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "b", "b", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
var factors1 = new EvidenceFactors { Assumptions = halfValidated };
|
||||
var factors2 = new EvidenceFactors { Assumptions = fullyValidated };
|
||||
|
||||
var result1 = _scorer.Calculate(factors1);
|
||||
var result2 = _scorer.Calculate(factors2);
|
||||
|
||||
result2.FactorBreakdown["assumption_validation"].Should().BeGreaterThan(result1.FactorBreakdown["assumption_validation"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Explanation_ReflectsLevel()
|
||||
{
|
||||
var lowFactors = new EvidenceFactors();
|
||||
var highFactors = new EvidenceFactors
|
||||
{
|
||||
HasStaticReachability = true,
|
||||
HasRuntimeObservations = true,
|
||||
HasVexAssessment = true,
|
||||
SourceCount = 3
|
||||
};
|
||||
|
||||
var lowResult = _scorer.Calculate(lowFactors);
|
||||
var highResult = _scorer.Calculate(highFactors);
|
||||
|
||||
lowResult.Explanation.Should().Contain("Low confidence");
|
||||
highResult.Explanation.Should().ContainAny("High confidence", "Very high confidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Recommendations_SuggestMissingEvidence()
|
||||
{
|
||||
var factors = new EvidenceFactors
|
||||
{
|
||||
HasStaticReachability = true
|
||||
// Missing: runtime, sbom, vex, assumptions, etc.
|
||||
};
|
||||
|
||||
var result = _scorer.Calculate(factors);
|
||||
|
||||
result.ImprovementRecommendations.Should().Contain(r => r.Contains("runtime"));
|
||||
result.ImprovementRecommendations.Should().Contain(r => r.Contains("VEX") || r.Contains("vendor"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, ConfidenceLevel.Low)]
|
||||
[InlineData(0.24, ConfidenceLevel.Low)]
|
||||
[InlineData(0.25, ConfidenceLevel.Medium)]
|
||||
[InlineData(0.49, ConfidenceLevel.Medium)]
|
||||
[InlineData(0.50, ConfidenceLevel.High)]
|
||||
[InlineData(0.74, ConfidenceLevel.High)]
|
||||
[InlineData(0.75, ConfidenceLevel.Verified)]
|
||||
[InlineData(1.0, ConfidenceLevel.Verified)]
|
||||
public void ScoreToLevel_MapsCorrectly(double score, ConfidenceLevel expectedLevel)
|
||||
{
|
||||
// We can't directly test the private method, but we can verify through integration
|
||||
// by checking that results with scores in certain ranges get the expected levels
|
||||
var result = new EvidenceDensityScore
|
||||
{
|
||||
Score = score,
|
||||
Level = score switch
|
||||
{
|
||||
>= 0.75 => ConfidenceLevel.Verified,
|
||||
>= 0.50 => ConfidenceLevel.High,
|
||||
>= 0.25 => ConfidenceLevel.Medium,
|
||||
_ => ConfidenceLevel.Low
|
||||
},
|
||||
FactorBreakdown = new Dictionary<string, double>(),
|
||||
Explanation = "test",
|
||||
ImprovementRecommendations = []
|
||||
};
|
||||
|
||||
result.Level.Should().Be(expectedLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Explainability.Confidence;
|
||||
using StellaOps.Scanner.Explainability.Dsse;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Dsse;
|
||||
|
||||
public class ExplainabilityPredicateSerializerTests
|
||||
{
|
||||
private readonly ExplainabilityPredicateSerializer _serializer = new();
|
||||
|
||||
[Fact]
|
||||
public void ToPredicate_MinimalReport_CreatesValidPredicate()
|
||||
{
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var predicate = _serializer.ToPredicate(report);
|
||||
|
||||
predicate.FindingId.Should().Be("finding-123");
|
||||
predicate.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
predicate.PackageName.Should().Be("test-pkg");
|
||||
predicate.PackageVersion.Should().Be("1.0.0");
|
||||
predicate.EngineVersion.Should().Be("1.0.0");
|
||||
predicate.Assumptions.Should().BeNull();
|
||||
predicate.Falsifiability.Should().BeNull();
|
||||
predicate.ConfidenceScore.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToPredicate_WithAssumptions_SerializesCorrectly()
|
||||
{
|
||||
var assumptions = new AssumptionSet
|
||||
{
|
||||
Id = "assumptions-123",
|
||||
ContextId = "finding-123",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
"enabled",
|
||||
AssumptionSource.RuntimeObservation,
|
||||
ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0",
|
||||
Assumptions = assumptions
|
||||
};
|
||||
|
||||
var predicate = _serializer.ToPredicate(report);
|
||||
|
||||
predicate.Assumptions.Should().NotBeNull();
|
||||
predicate.Assumptions!.Id.Should().Be("assumptions-123");
|
||||
predicate.Assumptions.Assumptions.Should().HaveCount(1);
|
||||
predicate.Assumptions.Assumptions[0].Category.Should().Be("CompilerFlag");
|
||||
predicate.Assumptions.Assumptions[0].Key.Should().Be("-fstack-protector");
|
||||
predicate.Assumptions.Assumptions[0].Source.Should().Be("RuntimeObservation");
|
||||
predicate.Assumptions.Assumptions[0].Confidence.Should().Be("Verified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToPredicate_WithFalsifiability_SerializesCorrectly()
|
||||
{
|
||||
var falsifiability = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "falsifiability-123",
|
||||
FindingId = "finding-123",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Status = FalsifiabilityStatus.Falsified,
|
||||
Summary = "Finding falsified",
|
||||
Criteria =
|
||||
[
|
||||
new FalsificationCriterion(
|
||||
FalsificationType.CodeUnreachable,
|
||||
"Code path is not reachable",
|
||||
"reachability.check()",
|
||||
"Static analysis confirmed",
|
||||
CriterionStatus.Satisfied)
|
||||
]
|
||||
};
|
||||
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0",
|
||||
Falsifiability = falsifiability
|
||||
};
|
||||
|
||||
var predicate = _serializer.ToPredicate(report);
|
||||
|
||||
predicate.Falsifiability.Should().NotBeNull();
|
||||
predicate.Falsifiability!.Id.Should().Be("falsifiability-123");
|
||||
predicate.Falsifiability.Status.Should().Be("Falsified");
|
||||
predicate.Falsifiability.Criteria.Should().HaveCount(1);
|
||||
predicate.Falsifiability.Criteria[0].Type.Should().Be("CodeUnreachable");
|
||||
predicate.Falsifiability.Criteria[0].Status.Should().Be("Satisfied");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToPredicate_WithConfidenceScore_SerializesCorrectly()
|
||||
{
|
||||
var score = new EvidenceDensityScore
|
||||
{
|
||||
Score = 0.75,
|
||||
Level = ConfidenceLevel.High,
|
||||
FactorBreakdown = new Dictionary<string, double>
|
||||
{
|
||||
["static_reachability"] = 0.15,
|
||||
["runtime_observations"] = 0.20
|
||||
},
|
||||
Explanation = "High confidence based on evidence",
|
||||
ImprovementRecommendations = ["Add VEX assessment"]
|
||||
};
|
||||
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0",
|
||||
ConfidenceScore = score
|
||||
};
|
||||
|
||||
var predicate = _serializer.ToPredicate(report);
|
||||
|
||||
predicate.ConfidenceScore.Should().NotBeNull();
|
||||
predicate.ConfidenceScore!.Score.Should().Be(0.75);
|
||||
predicate.ConfidenceScore.Level.Should().Be("High");
|
||||
predicate.ConfidenceScore.FactorBreakdown.Should().ContainKey("static_reachability");
|
||||
predicate.ConfidenceScore.ImprovementRecommendations.Should().Contain("Add VEX assessment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToPredicate_WithRecommendedActions_SerializesCorrectly()
|
||||
{
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0",
|
||||
RecommendedActions =
|
||||
[
|
||||
new RecommendedAction(1, "Update package", "Fix available", EffortLevel.Low),
|
||||
new RecommendedAction(2, "Review code", "Verify impact", EffortLevel.Medium)
|
||||
]
|
||||
};
|
||||
|
||||
var predicate = _serializer.ToPredicate(report);
|
||||
|
||||
predicate.RecommendedActions.Should().HaveCount(2);
|
||||
predicate.RecommendedActions![0].Priority.Should().Be(1);
|
||||
predicate.RecommendedActions[0].Action.Should().Be("Update package");
|
||||
predicate.RecommendedActions[0].Effort.Should().Be("Low");
|
||||
predicate.RecommendedActions[1].Effort.Should().Be("Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ProducesValidJson()
|
||||
{
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero),
|
||||
EngineVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var bytes = _serializer.Serialize(report);
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
|
||||
json.Should().Contain("\"findingId\":\"finding-123\"");
|
||||
json.Should().Contain("\"vulnerabilityId\":\"CVE-2024-1234\"");
|
||||
json.Should().Contain("\"packageName\":\"test-pkg\"");
|
||||
|
||||
// Verify it's valid JSON
|
||||
var action = () => JsonDocument.Parse(json);
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_UsesCorrectCasing()
|
||||
{
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0",
|
||||
ConfidenceScore = new EvidenceDensityScore
|
||||
{
|
||||
Score = 0.5,
|
||||
Level = ConfidenceLevel.Medium,
|
||||
FactorBreakdown = new Dictionary<string, double>(),
|
||||
Explanation = "test",
|
||||
ImprovementRecommendations = []
|
||||
}
|
||||
};
|
||||
|
||||
var bytes = _serializer.Serialize(report);
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
|
||||
// Should use camelCase
|
||||
json.Should().Contain("findingId");
|
||||
json.Should().NotContain("FindingId");
|
||||
json.Should().Contain("confidenceScore");
|
||||
json.Should().NotContain("ConfidenceScore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredicateType_ReturnsCorrectUri()
|
||||
{
|
||||
IExplainabilityPredicateSerializer.PredicateType.Should().Be(
|
||||
"https://stella-ops.org/predicates/finding-explainability/v2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_OmitsNullValues()
|
||||
{
|
||||
var report = new RiskReport
|
||||
{
|
||||
Id = "report-123",
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "test-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Explanation = "Test explanation",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
EngineVersion = "1.0.0"
|
||||
// Assumptions, Falsifiability, ConfidenceScore are null
|
||||
};
|
||||
|
||||
var bytes = _serializer.Serialize(report);
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
|
||||
// Null values should be omitted
|
||||
json.Should().NotContain("assumptions");
|
||||
json.Should().NotContain("falsifiability");
|
||||
json.Should().NotContain("confidenceScore");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Falsifiability;
|
||||
|
||||
public class FalsifiabilityCriteriaTests
|
||||
{
|
||||
[Fact]
|
||||
public void FalsifiabilityCriteria_DefaultState_HasEmptyCriteria()
|
||||
{
|
||||
var criteria = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "test-id",
|
||||
FindingId = "finding-123",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
criteria.Criteria.Should().BeEmpty();
|
||||
criteria.Status.Should().Be(FalsifiabilityStatus.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FalsificationCriterion_StoresAllProperties()
|
||||
{
|
||||
var criterion = new FalsificationCriterion(
|
||||
FalsificationType.CodeUnreachable,
|
||||
"Code is not reachable",
|
||||
"reachability.isReachable() == false",
|
||||
"Static analysis confirms unreachable",
|
||||
CriterionStatus.Satisfied);
|
||||
|
||||
criterion.Type.Should().Be(FalsificationType.CodeUnreachable);
|
||||
criterion.Description.Should().Be("Code is not reachable");
|
||||
criterion.CheckExpression.Should().Be("reachability.isReachable() == false");
|
||||
criterion.Evidence.Should().Be("Static analysis confirms unreachable");
|
||||
criterion.Status.Should().Be(CriterionStatus.Satisfied);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FalsificationType.PackageNotPresent)]
|
||||
[InlineData(FalsificationType.VersionMismatch)]
|
||||
[InlineData(FalsificationType.CodeUnreachable)]
|
||||
[InlineData(FalsificationType.FeatureDisabled)]
|
||||
[InlineData(FalsificationType.MitigationPresent)]
|
||||
[InlineData(FalsificationType.NoNetworkExposure)]
|
||||
[InlineData(FalsificationType.InsufficientPrivileges)]
|
||||
[InlineData(FalsificationType.PatchApplied)]
|
||||
[InlineData(FalsificationType.ConfigurationPrevents)]
|
||||
[InlineData(FalsificationType.RuntimePrevents)]
|
||||
public void FalsificationType_AllValuesAreValid(FalsificationType type)
|
||||
{
|
||||
var criterion = new FalsificationCriterion(
|
||||
type,
|
||||
"Test description",
|
||||
null,
|
||||
null,
|
||||
CriterionStatus.Pending);
|
||||
|
||||
criterion.Type.Should().Be(type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CriterionStatus.Pending)]
|
||||
[InlineData(CriterionStatus.Satisfied)]
|
||||
[InlineData(CriterionStatus.NotSatisfied)]
|
||||
[InlineData(CriterionStatus.Inconclusive)]
|
||||
public void CriterionStatus_AllValuesAreValid(CriterionStatus status)
|
||||
{
|
||||
var criterion = new FalsificationCriterion(
|
||||
FalsificationType.PackageNotPresent,
|
||||
"Test",
|
||||
null,
|
||||
null,
|
||||
status);
|
||||
|
||||
criterion.Status.Should().Be(status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FalsifiabilityStatus.Unknown)]
|
||||
[InlineData(FalsifiabilityStatus.Falsified)]
|
||||
[InlineData(FalsifiabilityStatus.NotFalsified)]
|
||||
[InlineData(FalsifiabilityStatus.PartiallyEvaluated)]
|
||||
public void FalsifiabilityStatus_AllValuesAreValid(FalsifiabilityStatus status)
|
||||
{
|
||||
var criteria = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "test",
|
||||
FindingId = "finding",
|
||||
Status = status,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
criteria.Status.Should().Be(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests.Falsifiability;
|
||||
|
||||
public class FalsifiabilityGeneratorTests
|
||||
{
|
||||
private readonly FalsifiabilityGenerator _generator = new(NullLogger<FalsifiabilityGenerator>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Generate_MinimalInput_CreatesBasicCriteria()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.FindingId.Should().Be("finding-123");
|
||||
result.Criteria.Should().ContainSingle(c => c.Type == FalsificationType.PackageNotPresent);
|
||||
result.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithVulnerableRange_AddsVersionMismatchCriterion()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
VulnerableRange = ">=1.0.0 <2.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Criteria.Should().Contain(c => c.Type == FalsificationType.VersionMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithFixedVersion_AddsPatchAppliedCriterion()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Criteria.Should().Contain(c => c.Type == FalsificationType.PatchApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithReachabilityData_UnreachableCode_CreatesSatisfiedCriterion()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
HasReachabilityData = true,
|
||||
IsReachable = false
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
var reachabilityCriterion = result.Criteria.FirstOrDefault(c => c.Type == FalsificationType.CodeUnreachable);
|
||||
reachabilityCriterion.Should().NotBeNull();
|
||||
reachabilityCriterion!.Status.Should().Be(CriterionStatus.Satisfied);
|
||||
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithReachabilityData_ReachableCode_CreatesNotSatisfiedCriterion()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
HasReachabilityData = true,
|
||||
IsReachable = true
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
var reachabilityCriterion = result.Criteria.FirstOrDefault(c => c.Type == FalsificationType.CodeUnreachable);
|
||||
reachabilityCriterion.Should().NotBeNull();
|
||||
reachabilityCriterion!.Status.Should().Be(CriterionStatus.NotSatisfied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithMitigations_CreatesSatisfiedCriteria()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
Mitigations = ["ASLR enabled", "Stack canaries"]
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
var mitigationCriteria = result.Criteria.Where(c => c.Type == FalsificationType.MitigationPresent).ToList();
|
||||
mitigationCriteria.Should().HaveCount(2);
|
||||
mitigationCriteria.Should().OnlyContain(c => c.Status == CriterionStatus.Satisfied);
|
||||
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithContradictedAssumptions_AddsCriteria()
|
||||
{
|
||||
var assumptions = new AssumptionSet
|
||||
{
|
||||
Id = "assumptions-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(
|
||||
AssumptionCategory.NetworkExposure,
|
||||
"port-443",
|
||||
"open",
|
||||
"closed",
|
||||
AssumptionSource.RuntimeObservation,
|
||||
ConfidenceLevel.Verified)
|
||||
]
|
||||
};
|
||||
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0",
|
||||
Assumptions = assumptions
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Criteria.Should().Contain(c => c.Type == FalsificationType.NoNetworkExposure);
|
||||
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_NoCriteriaSatisfied_ReturnsPartiallyEvaluated()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
// Only pending criteria (package presence check)
|
||||
result.Status.Should().Be(FalsifiabilityStatus.PartiallyEvaluated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_Summary_IncludesFindingId()
|
||||
{
|
||||
var input = new FalsifiabilityInput
|
||||
{
|
||||
FindingId = "finding-xyz",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
InstalledVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Summary.Should().Contain("finding-xyz");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Explainability.Confidence;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Tests;
|
||||
|
||||
public class RiskReportTests
|
||||
{
|
||||
private readonly RiskReportGenerator _generator;
|
||||
|
||||
public RiskReportTests()
|
||||
{
|
||||
_generator = new RiskReportGenerator(new EvidenceDensityScorer());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_MinimalInput_CreatesReport()
|
||||
{
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.FindingId.Should().Be("finding-123");
|
||||
result.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
result.PackageName.Should().Be("vulnerable-pkg");
|
||||
result.PackageVersion.Should().Be("1.0.0");
|
||||
result.Explanation.Should().Contain("CVE-2024-1234");
|
||||
result.EngineVersion.Should().Be("1.0.0");
|
||||
result.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithSeverity_IncludesInExplanation()
|
||||
{
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Severity = "CRITICAL"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Explanation.Should().Contain("CRITICAL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithFixedVersion_RecommendsUpdate()
|
||||
{
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.RecommendedActions.Should().Contain(a =>
|
||||
a.Action.Contains("Update") && a.Action.Contains("1.0.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithoutFixedVersion_RecommendsMonitoring()
|
||||
{
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.RecommendedActions.Should().Contain(a =>
|
||||
a.Action.Contains("Monitor") || a.Action.Contains("compensating"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEvidenceFactors_CalculatesConfidence()
|
||||
{
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
EvidenceFactors = new EvidenceFactors
|
||||
{
|
||||
HasStaticReachability = true,
|
||||
HasRuntimeObservations = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.ConfidenceScore.Should().NotBeNull();
|
||||
result.ConfidenceScore!.Score.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithAssumptions_IncludesInReport()
|
||||
{
|
||||
var assumptions = new AssumptionSet
|
||||
{
|
||||
Id = "assumptions-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(
|
||||
AssumptionCategory.CompilerFlag,
|
||||
"-fstack-protector",
|
||||
"enabled",
|
||||
"enabled",
|
||||
AssumptionSource.StaticAnalysis,
|
||||
ConfidenceLevel.High)
|
||||
]
|
||||
};
|
||||
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Assumptions = assumptions
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Assumptions.Should().BeSameAs(assumptions);
|
||||
result.DetailedNarrative.Should().Contain("Assumptions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithFalsifiability_IncludesInReport()
|
||||
{
|
||||
var falsifiability = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "falsifiability-id",
|
||||
FindingId = "finding-123",
|
||||
Status = FalsifiabilityStatus.Falsified,
|
||||
Summary = "Finding has been falsified",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Criteria =
|
||||
[
|
||||
new FalsificationCriterion(
|
||||
FalsificationType.CodeUnreachable,
|
||||
"Code is unreachable",
|
||||
null,
|
||||
null,
|
||||
CriterionStatus.Satisfied)
|
||||
]
|
||||
};
|
||||
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Falsifiability = falsifiability
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.Falsifiability.Should().BeSameAs(falsifiability);
|
||||
result.Explanation.Should().Contain("falsified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithUnvalidatedAssumptions_RecommendsValidation()
|
||||
{
|
||||
var assumptions = new AssumptionSet
|
||||
{
|
||||
Id = "assumptions-id",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Assumptions =
|
||||
[
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low),
|
||||
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
|
||||
]
|
||||
};
|
||||
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Assumptions = assumptions
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.RecommendedActions.Should().Contain(a =>
|
||||
a.Action.Contains("Validate") || a.Action.Contains("assumption"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithPartiallyEvaluatedFalsifiability_RecommendsCompletion()
|
||||
{
|
||||
var falsifiability = new FalsifiabilityCriteria
|
||||
{
|
||||
Id = "falsifiability-id",
|
||||
FindingId = "finding-123",
|
||||
Status = FalsifiabilityStatus.PartiallyEvaluated,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Criteria =
|
||||
[
|
||||
new FalsificationCriterion(FalsificationType.CodeUnreachable, "desc", null, null, CriterionStatus.Pending)
|
||||
]
|
||||
};
|
||||
|
||||
var input = new RiskReportInput
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PackageName = "vulnerable-pkg",
|
||||
PackageVersion = "1.0.0",
|
||||
Falsifiability = falsifiability
|
||||
};
|
||||
|
||||
var result = _generator.Generate(input);
|
||||
|
||||
result.RecommendedActions.Should().Contain(a =>
|
||||
a.Action.Contains("falsifiability") || a.Action.Contains("evaluation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecommendedAction_HasRequiredProperties()
|
||||
{
|
||||
var action = new RecommendedAction(
|
||||
Priority: 1,
|
||||
Action: "Update package",
|
||||
Rationale: "Fix is available",
|
||||
Effort: EffortLevel.Low);
|
||||
|
||||
action.Priority.Should().Be(1);
|
||||
action.Action.Should().Be("Update package");
|
||||
action.Rationale.Should().Be("Fix is available");
|
||||
action.Effort.Should().Be(EffortLevel.Low);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EffortLevel.Low)]
|
||||
[InlineData(EffortLevel.Medium)]
|
||||
[InlineData(EffortLevel.High)]
|
||||
public void EffortLevel_AllValuesAreValid(EffortLevel effort)
|
||||
{
|
||||
var action = new RecommendedAction(1, "Test", "Test", effort);
|
||||
action.Effort.Should().Be(effort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Explainability\StellaOps.Scanner.Explainability.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,397 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4400_0001_0001_signed_delta_verdict
|
||||
// Task: DELTA-008 - Integration tests for delta verdict attestation
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.DeltaVerdict.Models;
|
||||
using StellaOps.DeltaVerdict.Oci;
|
||||
using StellaOps.DeltaVerdict.Serialization;
|
||||
using StellaOps.DeltaVerdict.Signing;
|
||||
using StellaOps.Scanner.SmartDiff.Attestation;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiffTests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for delta verdict attestation flow.
|
||||
/// Sprint: SPRINT_4400_0001_0001 - Signed Delta Verdict Attestation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "4400.1")]
|
||||
public sealed class DeltaVerdictAttestationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
#region End-to-End Flow Tests
|
||||
|
||||
[Fact(DisplayName = "Delta verdict build and sign produces valid attestation")]
|
||||
public async Task BuildAndSign_ProducesValidAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var signer = new DeltaSigningService();
|
||||
|
||||
var request = CreateBuildRequest();
|
||||
|
||||
// Act - Build statement
|
||||
var statement = builder.BuildStatement(request);
|
||||
|
||||
// Assert - Statement structure
|
||||
statement.Should().NotBeNull();
|
||||
statement.PredicateType.Should().Be("delta-verdict.stella/v1");
|
||||
statement.Subject.Should().HaveCount(2);
|
||||
statement.Predicate.Should().NotBeNull();
|
||||
statement.Predicate.HasMaterialChange.Should().BeTrue();
|
||||
|
||||
// Act - Sign
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
var signedDelta = await signer.SignAsync(delta, new SigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
PayloadType = "application/vnd.stellaops.delta-verdict+json",
|
||||
SecretBase64 = Convert.ToBase64String("test-secret-key-32bytes!"u8.ToArray()),
|
||||
Algorithm = SigningAlgorithm.HmacSha256
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Assert - Signing
|
||||
signedDelta.Envelope.Should().NotBeNull();
|
||||
signedDelta.Envelope.Signatures.Should().NotBeEmpty();
|
||||
signedDelta.Envelope.Signatures[0].KeyId.Should().Be("test-key");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Signed delta can be verified")]
|
||||
public async Task SignedDelta_CanBeVerified()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var signer = new DeltaSigningService();
|
||||
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
var secret = Convert.ToBase64String("verification-secret-key-32bytes!"u8.ToArray());
|
||||
|
||||
// Act - Sign
|
||||
var signedDelta = await signer.SignAsync(delta, new SigningOptions
|
||||
{
|
||||
KeyId = "verification-key",
|
||||
PayloadType = "application/vnd.stellaops.delta-verdict+json",
|
||||
SecretBase64 = secret,
|
||||
Algorithm = SigningAlgorithm.HmacSha256
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act - Verify
|
||||
var verifyResult = await signer.VerifyAsync(signedDelta, new VerificationOptions
|
||||
{
|
||||
KeyId = "verification-key",
|
||||
SecretBase64 = secret,
|
||||
Algorithm = SigningAlgorithm.HmacSha256
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
verifyResult.IsValid.Should().BeTrue();
|
||||
verifyResult.Error.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Verification fails with wrong key")]
|
||||
public async Task Verification_FailsWithWrongKey()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var signer = new DeltaSigningService();
|
||||
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
// Act - Sign with one key
|
||||
var signedDelta = await signer.SignAsync(delta, new SigningOptions
|
||||
{
|
||||
KeyId = "signing-key",
|
||||
PayloadType = "application/vnd.stellaops.delta-verdict+json",
|
||||
SecretBase64 = Convert.ToBase64String("correct-secret-key-32bytes!"u8.ToArray()),
|
||||
Algorithm = SigningAlgorithm.HmacSha256
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act - Verify with different key
|
||||
var verifyResult = await signer.VerifyAsync(signedDelta, new VerificationOptions
|
||||
{
|
||||
KeyId = "signing-key",
|
||||
SecretBase64 = Convert.ToBase64String("wrong-secret-key-32bytes!!"u8.ToArray()),
|
||||
Algorithm = SigningAlgorithm.HmacSha256
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
verifyResult.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OCI Attachment Tests
|
||||
|
||||
[Fact(DisplayName = "OCI attachment can be created from delta verdict")]
|
||||
public void OciAttachment_CanBeCreatedFromDeltaVerdict()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var attacher = new DeltaOciAttacher();
|
||||
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
// Act
|
||||
var attachment = attacher.CreateAttachment(delta, "registry.example.com/repo@sha256:target123");
|
||||
|
||||
// Assert
|
||||
attachment.Should().NotBeNull();
|
||||
attachment.ArtifactReference.Should().Be("registry.example.com/repo@sha256:target123");
|
||||
attachment.MediaType.Should().Be("application/vnd.stellaops.delta-verdict+json");
|
||||
attachment.Payload.Should().NotBeEmpty();
|
||||
attachment.Annotations.Should().ContainKey("org.stellaops.delta.digest");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "OCI attachment includes before and after digests")]
|
||||
public void OciAttachment_IncludesBeforeAndAfterDigests()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var attacher = new DeltaOciAttacher();
|
||||
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
// Act
|
||||
var attachment = attacher.CreateAttachment(delta, "registry.example.com/repo@sha256:target123");
|
||||
|
||||
// Assert
|
||||
attachment.Annotations.Should().ContainKey("org.stellaops.delta.before");
|
||||
attachment.Annotations.Should().ContainKey("org.stellaops.delta.after");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Round-Trip Tests
|
||||
|
||||
[Fact(DisplayName = "Delta verdict serializes and deserializes correctly")]
|
||||
public void DeltaVerdict_RoundTrip_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
// Act
|
||||
var json = DeltaVerdictSerializer.Serialize(delta);
|
||||
var deserialized = DeltaVerdictSerializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized.BeforeDigest.Should().Be(delta.BeforeDigest);
|
||||
deserialized.AfterDigest.Should().Be(delta.AfterDigest);
|
||||
deserialized.HasMaterialChange.Should().Be(delta.HasMaterialChange);
|
||||
deserialized.PriorityScore.Should().Be(delta.PriorityScore);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serialization is deterministic")]
|
||||
public void Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var request = CreateBuildRequest();
|
||||
var statement = builder.BuildStatement(request);
|
||||
var delta = CreateDeltaVerdictFromStatement(statement);
|
||||
|
||||
// Act
|
||||
var json1 = DeltaVerdictSerializer.Serialize(delta);
|
||||
var json2 = DeltaVerdictSerializer.Serialize(delta);
|
||||
|
||||
// Assert
|
||||
json1.Should().Be(json2, "Serialization must be deterministic");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Predicate Tests
|
||||
|
||||
[Fact(DisplayName = "Predicate includes all material changes")]
|
||||
public void Predicate_IncludesAllMaterialChanges()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var request = CreateBuildRequestWithMultipleChanges();
|
||||
|
||||
// Act
|
||||
var statement = builder.BuildStatement(request);
|
||||
|
||||
// Assert
|
||||
statement.Predicate.Changes.Should().HaveCount(3);
|
||||
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R1");
|
||||
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R2");
|
||||
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R3");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Priority score is sum of individual scores")]
|
||||
public void PriorityScore_IsSumOfIndividualScores()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var request = CreateBuildRequest();
|
||||
|
||||
// Act
|
||||
var statement = builder.BuildStatement(request);
|
||||
|
||||
// Assert - Single change with score 100
|
||||
statement.Predicate.PriorityScore.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Statement includes proof spine references")]
|
||||
public void Statement_IncludesProofSpineReferences()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaVerdictBuilder();
|
||||
var request = CreateBuildRequest();
|
||||
|
||||
// Act
|
||||
var statement = builder.BuildStatement(request);
|
||||
|
||||
// Assert
|
||||
statement.Predicate.BeforeProofSpineDigest.Should().Be("sha256:spine-before");
|
||||
statement.Predicate.AfterProofSpineDigest.Should().Be("sha256:spine-after");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DeltaVerdictBuildRequest CreateBuildRequest()
|
||||
{
|
||||
var changes = new[]
|
||||
{
|
||||
new MaterialRiskChangeResult(
|
||||
FindingKey: new FindingKey("CVE-2025-0001", "pkg:npm/lodash@4.17.20"),
|
||||
HasMaterialChange: true,
|
||||
Changes: ImmutableArray.Create(new DetectedChange(
|
||||
Rule: DetectionRule.R1_ReachabilityFlip,
|
||||
ChangeType: MaterialChangeType.ReachabilityFlip,
|
||||
Direction: RiskDirection.Increased,
|
||||
Reason: "Reachability changed from false to true",
|
||||
PreviousValue: "false",
|
||||
CurrentValue: "true",
|
||||
Weight: 1.0)),
|
||||
PriorityScore: 100,
|
||||
PreviousStateHash: "sha256:prev-state",
|
||||
CurrentStateHash: "sha256:curr-state")
|
||||
};
|
||||
|
||||
return new DeltaVerdictBuildRequest
|
||||
{
|
||||
BeforeRevisionId = "rev-baseline",
|
||||
AfterRevisionId = "rev-current",
|
||||
BeforeImageDigest = "sha256:before123",
|
||||
AfterImageDigest = "sha256:after456",
|
||||
Changes = changes,
|
||||
ComparedAt = new DateTimeOffset(2025, 12, 22, 12, 0, 0, TimeSpan.Zero),
|
||||
BeforeProofSpine = new AttestationReference { Digest = "sha256:spine-before" },
|
||||
AfterProofSpine = new AttestationReference { Digest = "sha256:spine-after" }
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaVerdictBuildRequest CreateBuildRequestWithMultipleChanges()
|
||||
{
|
||||
var changes = new[]
|
||||
{
|
||||
new MaterialRiskChangeResult(
|
||||
FindingKey: new FindingKey("CVE-2025-0001", "pkg:npm/a@1.0.0"),
|
||||
HasMaterialChange: true,
|
||||
Changes: ImmutableArray.Create(new DetectedChange(
|
||||
Rule: DetectionRule.R1_ReachabilityFlip,
|
||||
ChangeType: MaterialChangeType.ReachabilityFlip,
|
||||
Direction: RiskDirection.Increased,
|
||||
Reason: "Reachability flip",
|
||||
PreviousValue: "false",
|
||||
CurrentValue: "true",
|
||||
Weight: 1.0)),
|
||||
PriorityScore: 100,
|
||||
PreviousStateHash: "sha256:prev1",
|
||||
CurrentStateHash: "sha256:curr1"),
|
||||
new MaterialRiskChangeResult(
|
||||
FindingKey: new FindingKey("CVE-2025-0002", "pkg:npm/b@1.0.0"),
|
||||
HasMaterialChange: true,
|
||||
Changes: ImmutableArray.Create(new DetectedChange(
|
||||
Rule: DetectionRule.R2_VexFlip,
|
||||
ChangeType: MaterialChangeType.VexFlip,
|
||||
Direction: RiskDirection.Decreased,
|
||||
Reason: "VEX status changed",
|
||||
PreviousValue: "affected",
|
||||
CurrentValue: "not_affected",
|
||||
Weight: 0.8)),
|
||||
PriorityScore: 50,
|
||||
PreviousStateHash: "sha256:prev2",
|
||||
CurrentStateHash: "sha256:curr2"),
|
||||
new MaterialRiskChangeResult(
|
||||
FindingKey: new FindingKey("CVE-2025-0003", "pkg:npm/c@1.0.0"),
|
||||
HasMaterialChange: true,
|
||||
Changes: ImmutableArray.Create(new DetectedChange(
|
||||
Rule: DetectionRule.R3_SeverityEscalation,
|
||||
ChangeType: MaterialChangeType.SeverityChange,
|
||||
Direction: RiskDirection.Increased,
|
||||
Reason: "Severity escalated",
|
||||
PreviousValue: "medium",
|
||||
CurrentValue: "critical",
|
||||
Weight: 1.0)),
|
||||
PriorityScore: 200,
|
||||
PreviousStateHash: "sha256:prev3",
|
||||
CurrentStateHash: "sha256:curr3")
|
||||
};
|
||||
|
||||
return new DeltaVerdictBuildRequest
|
||||
{
|
||||
BeforeRevisionId = "rev-baseline",
|
||||
AfterRevisionId = "rev-current",
|
||||
BeforeImageDigest = "sha256:before123",
|
||||
AfterImageDigest = "sha256:after456",
|
||||
Changes = changes,
|
||||
ComparedAt = new DateTimeOffset(2025, 12, 22, 12, 0, 0, TimeSpan.Zero),
|
||||
BeforeProofSpine = new AttestationReference { Digest = "sha256:spine-before" },
|
||||
AfterProofSpine = new AttestationReference { Digest = "sha256:spine-after" }
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaVerdict CreateDeltaVerdictFromStatement(DeltaVerdictStatement statement)
|
||||
{
|
||||
return new DeltaVerdict
|
||||
{
|
||||
BeforeDigest = statement.Subject[0].Digest.Values.First(),
|
||||
AfterDigest = statement.Subject[1].Digest.Values.First(),
|
||||
BeforeRevisionId = statement.Predicate.BeforeRevisionId,
|
||||
AfterRevisionId = statement.Predicate.AfterRevisionId,
|
||||
HasMaterialChange = statement.Predicate.HasMaterialChange,
|
||||
PriorityScore = statement.Predicate.PriorityScore,
|
||||
ComparedAt = statement.Predicate.ComparedAt,
|
||||
Changes = statement.Predicate.Changes
|
||||
.Select(c => new DeltaChange
|
||||
{
|
||||
Rule = c.Rule,
|
||||
FindingKey = c.FindingKey,
|
||||
Direction = c.Direction,
|
||||
Reason = c.Reason
|
||||
})
|
||||
.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4400_0001_0002_reachability_subgraph_attestation
|
||||
// Task: SUBG-008 - Integration tests for reachability subgraph attestation
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiffTests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for reachability subgraph attestation flow.
|
||||
/// Sprint: SPRINT_4400_0001_0002 - Reachability Subgraph Attestation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "4400.2")]
|
||||
public sealed class ReachabilitySubgraphAttestationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
#region Subgraph Structure Tests
|
||||
|
||||
[Fact(DisplayName = "Subgraph contains entrypoint nodes")]
|
||||
public void Subgraph_ContainsEntrypointNodes()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.Nodes.Should().Contain(n => n.Type == "entrypoint");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Subgraph contains vulnerable nodes")]
|
||||
public void Subgraph_ContainsVulnerableNodes()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.Nodes.Should().Contain(n => n.Type == "vulnerable");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Subgraph has valid edge connections")]
|
||||
public void Subgraph_HasValidEdgeConnections()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
var nodeIds = subgraph.Nodes.Select(n => n.Id).ToHashSet();
|
||||
|
||||
// Assert - All edges reference valid nodes
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
nodeIds.Should().Contain(edge.From, $"Edge from node {edge.From} should exist");
|
||||
nodeIds.Should().Contain(edge.To, $"Edge to node {edge.To} should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Subgraph includes finding keys")]
|
||||
public void Subgraph_IncludesFindingKeys()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.FindingKeys.Should().NotBeEmpty();
|
||||
subgraph.FindingKeys.Should().Contain("CVE-2025-0001@pkg:npm/lodash@4.17.20");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Normalization Tests
|
||||
|
||||
[Fact(DisplayName = "Subgraph normalization is deterministic")]
|
||||
public void SubgraphNormalization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph1 = CreateTestSubgraph();
|
||||
var subgraph2 = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var normalized1 = NormalizeSubgraph(subgraph1);
|
||||
var normalized2 = NormalizeSubgraph(subgraph2);
|
||||
|
||||
var json1 = JsonSerializer.Serialize(normalized1, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(normalized2, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json1.Should().Be(json2, "Normalized subgraphs should be deterministic");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Normalization sorts nodes by ID")]
|
||||
public void Normalization_SortsNodesById()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateUnorderedSubgraph();
|
||||
|
||||
// Act
|
||||
var normalized = NormalizeSubgraph(subgraph);
|
||||
|
||||
// Assert
|
||||
var nodeIds = normalized.Nodes.Select(n => n.Id).ToList();
|
||||
nodeIds.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Normalization sorts edges")]
|
||||
public void Normalization_SortsEdges()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateUnorderedSubgraph();
|
||||
|
||||
// Act
|
||||
var normalized = NormalizeSubgraph(subgraph);
|
||||
|
||||
// Assert
|
||||
var edgeKeys = normalized.Edges.Select(e => $"{e.From}->{e.To}").ToList();
|
||||
edgeKeys.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[Fact(DisplayName = "Subgraph round-trips through JSON")]
|
||||
public void Subgraph_RoundTrips_ThroughJson()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(original, JsonOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TestReachabilitySubgraph>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Version.Should().Be(original.Version);
|
||||
deserialized.FindingKeys.Should().BeEquivalentTo(original.FindingKeys);
|
||||
deserialized.Nodes.Should().HaveCount(original.Nodes.Length);
|
||||
deserialized.Edges.Should().HaveCount(original.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Subgraph JSON matches expected format")]
|
||||
public void Subgraph_JsonFormat_MatchesExpected()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateMinimalSubgraph();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(subgraph, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"version\"");
|
||||
json.Should().Contain("\"findingKeys\"");
|
||||
json.Should().Contain("\"nodes\"");
|
||||
json.Should().Contain("\"edges\"");
|
||||
json.Should().Contain("\"analysisMetadata\"");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DOT Export Tests
|
||||
|
||||
[Fact(DisplayName = "DOT export includes digraph declaration")]
|
||||
public void DotExport_IncludesDigraphDeclaration()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var dot = GenerateDot(subgraph, null);
|
||||
|
||||
// Assert
|
||||
dot.Should().StartWith("digraph reachability {");
|
||||
dot.Should().EndWith("}\n");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DOT export includes all nodes")]
|
||||
public void DotExport_IncludesAllNodes()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var dot = GenerateDot(subgraph, null);
|
||||
|
||||
// Assert
|
||||
foreach (var node in subgraph.Nodes)
|
||||
{
|
||||
dot.Should().Contain($"\"{node.Id}\"");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DOT export includes all edges")]
|
||||
public void DotExport_IncludesAllEdges()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var dot = GenerateDot(subgraph, null);
|
||||
|
||||
// Assert
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
dot.Should().Contain($"\"{edge.From}\" -> \"{edge.To}\"");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DOT export colors nodes by type")]
|
||||
public void DotExport_ColorsNodesByType()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var dot = GenerateDot(subgraph, null);
|
||||
|
||||
// Assert
|
||||
dot.Should().Contain("lightgreen"); // Entrypoint
|
||||
dot.Should().Contain("lightcoral"); // Vulnerable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mermaid Export Tests
|
||||
|
||||
[Fact(DisplayName = "Mermaid export includes graph declaration")]
|
||||
public void MermaidExport_IncludesGraphDeclaration()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var mermaid = GenerateMermaid(subgraph, null);
|
||||
|
||||
// Assert
|
||||
mermaid.Should().Contain("graph LR");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Mermaid export includes subgraphs for node types")]
|
||||
public void MermaidExport_IncludesSubgraphsForNodeTypes()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var mermaid = GenerateMermaid(subgraph, null);
|
||||
|
||||
// Assert
|
||||
mermaid.Should().Contain("subgraph Entrypoints");
|
||||
mermaid.Should().Contain("subgraph Vulnerable");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Mermaid export includes class definitions")]
|
||||
public void MermaidExport_IncludesClassDefinitions()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Act
|
||||
var mermaid = GenerateMermaid(subgraph, null);
|
||||
|
||||
// Assert
|
||||
mermaid.Should().Contain("classDef entrypoint");
|
||||
mermaid.Should().Contain("classDef vulnerable");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Analysis Metadata Tests
|
||||
|
||||
[Fact(DisplayName = "Analysis metadata includes analyzer info")]
|
||||
public void AnalysisMetadata_IncludesAnalyzerInfo()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.AnalysisMetadata.Should().NotBeNull();
|
||||
subgraph.AnalysisMetadata!.Analyzer.Should().NotBeNullOrEmpty();
|
||||
subgraph.AnalysisMetadata.AnalyzerVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Analysis metadata includes confidence score")]
|
||||
public void AnalysisMetadata_IncludesConfidenceScore()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.AnalysisMetadata!.Confidence.Should().BeInRange(0, 1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Analysis metadata includes completeness")]
|
||||
public void AnalysisMetadata_IncludesCompleteness()
|
||||
{
|
||||
// Arrange
|
||||
var subgraph = CreateTestSubgraph();
|
||||
|
||||
// Assert
|
||||
subgraph.AnalysisMetadata!.Completeness.Should().BeOneOf("full", "partial", "sampling");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static TestReachabilitySubgraph CreateTestSubgraph()
|
||||
{
|
||||
return new TestReachabilitySubgraph
|
||||
{
|
||||
Version = "1.0",
|
||||
FindingKeys = new[] { "CVE-2025-0001@pkg:npm/lodash@4.17.20" },
|
||||
Nodes = new[]
|
||||
{
|
||||
new TestNode { Id = "n1", Type = "entrypoint", Symbol = "main.handler", File = "src/main.js", Line = 10 },
|
||||
new TestNode { Id = "n2", Type = "call", Symbol = "lodash.merge", File = "node_modules/lodash/merge.js", Line = 50 },
|
||||
new TestNode { Id = "n3", Type = "vulnerable", Symbol = "lodash._baseAssign", File = "node_modules/lodash/_baseAssign.js", Line = 12, Purl = "pkg:npm/lodash@4.17.20" }
|
||||
},
|
||||
Edges = new[]
|
||||
{
|
||||
new TestEdge { From = "n1", To = "n2", Type = "call", Confidence = 0.95 },
|
||||
new TestEdge { From = "n2", To = "n3", Type = "call", Confidence = 0.90 }
|
||||
},
|
||||
AnalysisMetadata = new TestAnalysisMetadata
|
||||
{
|
||||
Analyzer = "node-callgraph-v2",
|
||||
AnalyzerVersion = "2.1.0",
|
||||
Confidence = 0.92,
|
||||
Completeness = "partial"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static TestReachabilitySubgraph CreateUnorderedSubgraph()
|
||||
{
|
||||
return new TestReachabilitySubgraph
|
||||
{
|
||||
Version = "1.0",
|
||||
FindingKeys = new[] { "CVE-2025-0001" },
|
||||
Nodes = new[]
|
||||
{
|
||||
new TestNode { Id = "z-node", Type = "call", Symbol = "z.func" },
|
||||
new TestNode { Id = "a-node", Type = "entrypoint", Symbol = "a.main" },
|
||||
new TestNode { Id = "m-node", Type = "vulnerable", Symbol = "m.vuln" }
|
||||
},
|
||||
Edges = new[]
|
||||
{
|
||||
new TestEdge { From = "z-node", To = "m-node", Type = "call", Confidence = 1.0 },
|
||||
new TestEdge { From = "a-node", To = "z-node", Type = "call", Confidence = 1.0 }
|
||||
},
|
||||
AnalysisMetadata = new TestAnalysisMetadata
|
||||
{
|
||||
Analyzer = "test",
|
||||
AnalyzerVersion = "1.0",
|
||||
Confidence = 1.0,
|
||||
Completeness = "full"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static TestReachabilitySubgraph CreateMinimalSubgraph()
|
||||
{
|
||||
return new TestReachabilitySubgraph
|
||||
{
|
||||
Version = "1.0",
|
||||
FindingKeys = new[] { "CVE-2025-MINIMAL" },
|
||||
Nodes = new[]
|
||||
{
|
||||
new TestNode { Id = "entry", Type = "entrypoint", Symbol = "main" },
|
||||
new TestNode { Id = "vuln", Type = "vulnerable", Symbol = "vuln.func" }
|
||||
},
|
||||
Edges = new[]
|
||||
{
|
||||
new TestEdge { From = "entry", To = "vuln", Type = "call", Confidence = 1.0 }
|
||||
},
|
||||
AnalysisMetadata = new TestAnalysisMetadata
|
||||
{
|
||||
Analyzer = "minimal",
|
||||
AnalyzerVersion = "1.0",
|
||||
Confidence = 1.0,
|
||||
Completeness = "full"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static TestReachabilitySubgraph NormalizeSubgraph(TestReachabilitySubgraph subgraph)
|
||||
{
|
||||
return subgraph with
|
||||
{
|
||||
Nodes = subgraph.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal).ToArray(),
|
||||
Edges = subgraph.Edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
FindingKeys = subgraph.FindingKeys.OrderBy(k => k, StringComparer.Ordinal).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateDot(TestReachabilitySubgraph subgraph, string? title)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("digraph reachability {");
|
||||
sb.AppendLine(" rankdir=LR;");
|
||||
sb.AppendLine(" node [shape=box, fontname=\"Helvetica\"];");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
sb.AppendLine($" label=\"{title}\";");
|
||||
}
|
||||
|
||||
foreach (var node in subgraph.Nodes)
|
||||
{
|
||||
var color = node.Type switch
|
||||
{
|
||||
"entrypoint" => "lightgreen",
|
||||
"vulnerable" => "lightcoral",
|
||||
_ => "lightyellow"
|
||||
};
|
||||
|
||||
sb.AppendLine($" \"{node.Id}\" [label=\"{node.Symbol}\", fillcolor=\"{color}\", style=\"filled\"];");
|
||||
}
|
||||
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
sb.AppendLine($" \"{edge.From}\" -> \"{edge.To}\";");
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateMermaid(TestReachabilitySubgraph subgraph, string? title)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine($"title: {title}");
|
||||
sb.AppendLine("---");
|
||||
}
|
||||
|
||||
sb.AppendLine("graph LR");
|
||||
|
||||
var entrypoints = subgraph.Nodes.Where(n => n.Type == "entrypoint").ToList();
|
||||
var vulnerables = subgraph.Nodes.Where(n => n.Type == "vulnerable").ToList();
|
||||
|
||||
if (entrypoints.Count > 0)
|
||||
{
|
||||
sb.AppendLine(" subgraph Entrypoints");
|
||||
foreach (var node in entrypoints)
|
||||
{
|
||||
sb.AppendLine($" {node.Id}([{node.Symbol}])");
|
||||
}
|
||||
sb.AppendLine(" end");
|
||||
}
|
||||
|
||||
if (vulnerables.Count > 0)
|
||||
{
|
||||
sb.AppendLine(" subgraph Vulnerable");
|
||||
foreach (var node in vulnerables)
|
||||
{
|
||||
sb.AppendLine($" {node.Id}{{{{{node.Symbol}}}}}");
|
||||
}
|
||||
sb.AppendLine(" end");
|
||||
}
|
||||
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
sb.AppendLine($" {edge.From} --> {edge.To}");
|
||||
}
|
||||
|
||||
sb.AppendLine(" classDef entrypoint fill:#90EE90,stroke:#333");
|
||||
sb.AppendLine(" classDef vulnerable fill:#F08080,stroke:#333");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Models
|
||||
|
||||
public sealed record TestReachabilitySubgraph
|
||||
{
|
||||
public string Version { get; init; } = "1.0";
|
||||
public string[] FindingKeys { get; init; } = Array.Empty<string>();
|
||||
public TestNode[] Nodes { get; init; } = Array.Empty<TestNode>();
|
||||
public TestEdge[] Edges { get; init; } = Array.Empty<TestEdge>();
|
||||
public TestAnalysisMetadata? AnalysisMetadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TestNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Symbol { get; init; }
|
||||
public string? File { get; init; }
|
||||
public int? Line { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TestEdge
|
||||
{
|
||||
public required string From { get; init; }
|
||||
public required string To { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public TestGateInfo? Gate { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TestGateInfo
|
||||
{
|
||||
public required string GateType { get; init; }
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TestAnalysisMetadata
|
||||
{
|
||||
public required string Analyzer { get; init; }
|
||||
public required string AnalyzerVersion { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string Completeness { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -238,6 +238,53 @@ public sealed class SarifOutputGeneratorTests
|
||||
sarifLog.Runs[0].Invocations!.Value[0].StartTimeUtc.Should().Be(scanTime);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Attestation reference included in run properties")]
|
||||
[Trait("Sprint", "4400.1")]
|
||||
public void AttestationReference_IncludedInRunProperties()
|
||||
{
|
||||
// Arrange - Sprint SPRINT_4400_0001_0001 - DELTA-007
|
||||
var input = CreateBasicInput() with
|
||||
{
|
||||
Attestation = new AttestationReference(
|
||||
Digest: "sha256:attestation123",
|
||||
PredicateType: "delta-verdict.stella/v1",
|
||||
OciReference: "registry.example.com/repo@sha256:attestation123",
|
||||
RekorLogId: "1234567890",
|
||||
SignatureKeyId: "delta-dev")
|
||||
};
|
||||
|
||||
// Act
|
||||
var sarifLog = _generator.Generate(input);
|
||||
|
||||
// Assert
|
||||
sarifLog.Runs[0].Properties.Should().NotBeNull();
|
||||
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.attestation");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Base and target digests included in run properties")]
|
||||
[Trait("Sprint", "4400.1")]
|
||||
public void BaseAndTargetDigests_IncludedInRunProperties()
|
||||
{
|
||||
// Arrange
|
||||
var input = new SmartDiffSarifInput(
|
||||
ScannerVersion: "1.0.0",
|
||||
ScanTime: DateTimeOffset.UtcNow,
|
||||
BaseDigest: "sha256:base-digest-abc",
|
||||
TargetDigest: "sha256:target-digest-xyz",
|
||||
MaterialChanges: [],
|
||||
HardeningRegressions: [],
|
||||
VexCandidates: [],
|
||||
ReachabilityChanges: []);
|
||||
|
||||
// Act
|
||||
var sarifLog = _generator.Generate(input);
|
||||
|
||||
// Assert
|
||||
sarifLog.Runs[0].Properties.Should().NotBeNull();
|
||||
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.diff.base.digest");
|
||||
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.diff.target.digest");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests (SDIFF-BIN-027)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictE2ETests.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Task: VERDICT-015
|
||||
// Description: End-to-end tests for scan -> verdict push -> verify workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests validating the complete verdict attestation workflow:
|
||||
/// 1. Push a container image to registry
|
||||
/// 2. Create and push a verdict attestation as a referrer
|
||||
/// 3. Verify the verdict can be discovered and validated
|
||||
/// </summary>
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class VerdictE2ETests : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _registryContainer;
|
||||
private string _registryHost = string.Empty;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_registryContainer = new ContainerBuilder()
|
||||
.WithImage("registry:2")
|
||||
.WithPortBinding(5000, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5000))
|
||||
.Build();
|
||||
|
||||
await _registryContainer.StartAsync();
|
||||
|
||||
var port = _registryContainer.GetMappedPublicPort(5000);
|
||||
_registryHost = $"localhost:{port}";
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
if (_registryContainer is not null)
|
||||
{
|
||||
await _registryContainer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full E2E test: simulates a scan completion -> verdict push -> verification flow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task E2E_ScanVerdictPushVerify_CompletesSuccessfully()
|
||||
{
|
||||
// ===== PHASE 1: Simulate scan output (push base image) =====
|
||||
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/myapp");
|
||||
|
||||
// ===== PHASE 2: Create and push verdict attestation =====
|
||||
var scanResult = CreateMockScanResult(imageDigest);
|
||||
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/myapp", imageDigest, scanResult);
|
||||
|
||||
// ===== PHASE 3: Verify verdict via referrers API =====
|
||||
var verificationResult = await VerifyVerdictAsync("e2e-test/myapp", imageDigest, verdictDigest);
|
||||
|
||||
// Assert all phases completed successfully
|
||||
Assert.True(verificationResult.VerdictFound, "Verdict should be discoverable");
|
||||
Assert.Equal(scanResult.Decision, verificationResult.Decision);
|
||||
Assert.Equal(scanResult.SbomDigest, verificationResult.SbomDigest);
|
||||
Assert.Equal(scanResult.FeedsDigest, verificationResult.FeedsDigest);
|
||||
Assert.Equal(scanResult.PolicyDigest, verificationResult.PolicyDigest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// E2E test: multiple scan revisions create separate verdict attestations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task E2E_MultipleScanRevisions_CreatesMultipleVerdicts()
|
||||
{
|
||||
// Setup
|
||||
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/versioned");
|
||||
|
||||
// First scan/verdict
|
||||
var scanResult1 = new MockScanResult
|
||||
{
|
||||
Decision = "pass",
|
||||
SbomDigest = "sha256:sbom_rev1",
|
||||
FeedsDigest = "sha256:feeds_rev1",
|
||||
PolicyDigest = "sha256:policy_rev1",
|
||||
GraphRevisionId = "rev-001"
|
||||
};
|
||||
var verdict1 = await PushVerdictAttestationAsync("e2e-test/versioned", imageDigest, scanResult1);
|
||||
|
||||
// Second scan/verdict (updated feeds)
|
||||
var scanResult2 = new MockScanResult
|
||||
{
|
||||
Decision = "warn",
|
||||
SbomDigest = "sha256:sbom_rev1", // Same SBOM
|
||||
FeedsDigest = "sha256:feeds_rev2", // Updated feeds
|
||||
PolicyDigest = "sha256:policy_rev1",
|
||||
GraphRevisionId = "rev-002"
|
||||
};
|
||||
var verdict2 = await PushVerdictAttestationAsync("e2e-test/versioned", imageDigest, scanResult2);
|
||||
|
||||
// Verify both verdicts exist
|
||||
var verdicts = await ListVerdictsAsync("e2e-test/versioned", imageDigest);
|
||||
|
||||
Assert.Equal(2, verdicts.Count);
|
||||
Assert.Contains(verdicts, v => v.Decision == "pass" && v.GraphRevisionId == "rev-001");
|
||||
Assert.Contains(verdicts, v => v.Decision == "warn" && v.GraphRevisionId == "rev-002");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// E2E test: verdict with uncertainty attestation references (SPRINT_4300_0002_0002).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task E2E_VerdictWithUncertainty_IncludesUncertaintyDigests()
|
||||
{
|
||||
// Setup
|
||||
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/uncertain");
|
||||
|
||||
var scanResult = new MockScanResult
|
||||
{
|
||||
Decision = "pass",
|
||||
SbomDigest = "sha256:sbom_uncertain",
|
||||
FeedsDigest = "sha256:feeds_uncertain",
|
||||
PolicyDigest = "sha256:policy_uncertain",
|
||||
UncertaintyStatementDigest = "sha256:uncertainty_t2",
|
||||
UncertaintyBudgetDigest = "sha256:budget_passed"
|
||||
};
|
||||
|
||||
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/uncertain", imageDigest, scanResult);
|
||||
|
||||
// Fetch and verify manifest annotations include uncertainty
|
||||
var manifest = await FetchManifestAsync("e2e-test/uncertain", verdictDigest);
|
||||
|
||||
Assert.True(manifest.TryGetProperty("annotations", out var annotations));
|
||||
Assert.Equal("sha256:uncertainty_t2",
|
||||
annotations.GetProperty(OciAnnotations.StellaUncertaintyDigest).GetString());
|
||||
Assert.Equal("sha256:budget_passed",
|
||||
annotations.GetProperty(OciAnnotations.StellaUncertaintyBudgetDigest).GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// E2E test: verify verdict DSSE envelope can be fetched and parsed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task E2E_VerdictDsseEnvelope_CanBeFetchedAndParsed()
|
||||
{
|
||||
// Setup
|
||||
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/dsse");
|
||||
|
||||
var scanResult = CreateMockScanResult(imageDigest);
|
||||
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/dsse", imageDigest, scanResult);
|
||||
|
||||
// Fetch manifest to get layer digest
|
||||
var manifest = await FetchManifestAsync("e2e-test/dsse", verdictDigest);
|
||||
var layers = manifest.GetProperty("layers");
|
||||
Assert.Equal(1, layers.GetArrayLength());
|
||||
|
||||
var layerDigest = layers[0].GetProperty("digest").GetString();
|
||||
Assert.NotNull(layerDigest);
|
||||
|
||||
// Fetch the DSSE envelope blob
|
||||
var blobUrl = $"http://{_registryHost}/v2/e2e-test/dsse/blobs/{layerDigest}";
|
||||
var blobResponse = await _httpClient!.GetAsync(blobUrl);
|
||||
blobResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var envelopeBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||
var envelope = JsonSerializer.Deserialize<JsonElement>(envelopeBytes);
|
||||
|
||||
// Verify DSSE envelope structure
|
||||
Assert.Equal("verdict.stella/v1", envelope.GetProperty("payloadType").GetString());
|
||||
Assert.True(envelope.TryGetProperty("payload", out var payload));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetString()));
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
private async Task<string> SimulateScanAndPushImageAsync(string repository)
|
||||
{
|
||||
// Create a minimal image config (simulates scan target)
|
||||
var config = $$"""
|
||||
{
|
||||
"created": "{{DateTimeOffset.UtcNow:O}}",
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"rootfs": {"type": "layers", "diff_ids": []},
|
||||
"config": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var configBytes = Encoding.UTF8.GetBytes(config);
|
||||
var configDigest = ComputeSha256Digest(configBytes);
|
||||
await PushBlobAsync(repository, configDigest, configBytes);
|
||||
|
||||
// Create image manifest
|
||||
var manifest = $$"""
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.image.config.v1+json",
|
||||
"digest": "{{configDigest}}",
|
||||
"size": {{configBytes.Length}}
|
||||
},
|
||||
"layers": []
|
||||
}
|
||||
""";
|
||||
|
||||
var manifestBytes = Encoding.UTF8.GetBytes(manifest);
|
||||
var manifestDigest = ComputeSha256Digest(manifestBytes);
|
||||
|
||||
var manifestUrl = $"http://{_registryHost}/v2/{repository}/manifests/{manifestDigest}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl);
|
||||
request.Content = new ByteArrayContent(manifestBytes);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
|
||||
|
||||
var response = await _httpClient!.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return manifestDigest;
|
||||
}
|
||||
|
||||
private async Task<string> PushVerdictAttestationAsync(string repository, string imageDigest, MockScanResult scanResult)
|
||||
{
|
||||
var pusher = new OciArtifactPusher(
|
||||
_httpClient!,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = _registryHost },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/{repository}",
|
||||
ImageDigest = imageDigest,
|
||||
DsseEnvelopeBytes = CreateDsseEnvelope(scanResult),
|
||||
SbomDigest = scanResult.SbomDigest,
|
||||
FeedsDigest = scanResult.FeedsDigest,
|
||||
PolicyDigest = scanResult.PolicyDigest,
|
||||
Decision = scanResult.Decision,
|
||||
GraphRevisionId = scanResult.GraphRevisionId,
|
||||
VerdictTimestamp = DateTimeOffset.UtcNow,
|
||||
UncertaintyStatementDigest = scanResult.UncertaintyStatementDigest,
|
||||
UncertaintyBudgetDigest = scanResult.UncertaintyBudgetDigest
|
||||
};
|
||||
|
||||
var result = await verdictPublisher.PushAsync(request);
|
||||
Assert.True(result.Success, $"Verdict push failed: {result.Error}");
|
||||
|
||||
return result.ManifestDigest!;
|
||||
}
|
||||
|
||||
private async Task<VerdictVerificationInfo> VerifyVerdictAsync(string repository, string imageDigest, string expectedVerdictDigest)
|
||||
{
|
||||
// Query referrers API
|
||||
var referrersUrl = $"http://{_registryHost}/v2/{repository}/referrers/{imageDigest}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var referrersJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(referrersJson);
|
||||
|
||||
var manifests = doc.RootElement.GetProperty("manifests");
|
||||
|
||||
foreach (var manifest in manifests.EnumerateArray())
|
||||
{
|
||||
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
|
||||
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
|
||||
{
|
||||
var annotations = manifest.GetProperty("annotations");
|
||||
return new VerdictVerificationInfo
|
||||
{
|
||||
VerdictFound = true,
|
||||
VerdictDigest = manifest.GetProperty("digest").GetString(),
|
||||
Decision = annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString(),
|
||||
SbomDigest = annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString(),
|
||||
FeedsDigest = annotations.GetProperty(OciAnnotations.StellaFeedsDigest).GetString(),
|
||||
PolicyDigest = annotations.GetProperty(OciAnnotations.StellaPolicyDigest).GetString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new VerdictVerificationInfo { VerdictFound = false };
|
||||
}
|
||||
|
||||
private async Task<List<VerdictListItem>> ListVerdictsAsync(string repository, string imageDigest)
|
||||
{
|
||||
var referrersUrl = $"http://{_registryHost}/v2/{repository}/referrers/{imageDigest}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var referrersJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(referrersJson);
|
||||
|
||||
var results = new List<VerdictListItem>();
|
||||
var manifests = doc.RootElement.GetProperty("manifests");
|
||||
|
||||
foreach (var manifest in manifests.EnumerateArray())
|
||||
{
|
||||
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
|
||||
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
|
||||
{
|
||||
var annotations = manifest.GetProperty("annotations");
|
||||
results.Add(new VerdictListItem
|
||||
{
|
||||
Digest = manifest.GetProperty("digest").GetString()!,
|
||||
Decision = annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString()!,
|
||||
GraphRevisionId = annotations.TryGetProperty(OciAnnotations.StellaGraphRevisionId, out var rev)
|
||||
? rev.GetString()
|
||||
: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> FetchManifestAsync(string repository, string digest)
|
||||
{
|
||||
var manifestUrl = $"http://{_registryHost}/v2/{repository}/manifests/{digest}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var manifestJson = await response.Content.ReadAsStringAsync();
|
||||
return JsonDocument.Parse(manifestJson).RootElement.Clone();
|
||||
}
|
||||
|
||||
private async Task PushBlobAsync(string repository, string digest, byte[] content)
|
||||
{
|
||||
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
|
||||
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
|
||||
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
|
||||
initiateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var uploadLocation = initiateResponse.Headers.Location?.ToString();
|
||||
Assert.NotNull(uploadLocation);
|
||||
|
||||
var separator = uploadLocation.Contains('?') ? "&" : "?";
|
||||
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
|
||||
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
uploadUrl = $"http://{_registryHost}{uploadUrl}";
|
||||
}
|
||||
|
||||
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
|
||||
uploadRequest.Content = new ByteArrayContent(content);
|
||||
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
|
||||
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
|
||||
uploadResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static MockScanResult CreateMockScanResult(string imageDigest)
|
||||
{
|
||||
return new MockScanResult
|
||||
{
|
||||
Decision = "pass",
|
||||
SbomDigest = $"sha256:sbom_{imageDigest[7..19]}",
|
||||
FeedsDigest = $"sha256:feeds_{DateTimeOffset.UtcNow:yyyyMMddHH}",
|
||||
PolicyDigest = "sha256:policy_default_v1",
|
||||
GraphRevisionId = $"rev-{Guid.NewGuid():N}"[..16]
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateDsseEnvelope(MockScanResult scanResult)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
decision = scanResult.Decision,
|
||||
sbomDigest = scanResult.SbomDigest,
|
||||
feedsDigest = scanResult.FeedsDigest,
|
||||
policyDigest = scanResult.PolicyDigest,
|
||||
graphRevisionId = scanResult.GraphRevisionId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "verdict.stella/v1",
|
||||
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
|
||||
signatures = Array.Empty<object>()
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(envelope));
|
||||
}
|
||||
|
||||
// ===== Model Classes =====
|
||||
|
||||
private sealed class MockScanResult
|
||||
{
|
||||
public required string Decision { get; init; }
|
||||
public required string SbomDigest { get; init; }
|
||||
public required string FeedsDigest { get; init; }
|
||||
public required string PolicyDigest { get; init; }
|
||||
public string? GraphRevisionId { get; init; }
|
||||
public string? UncertaintyStatementDigest { get; init; }
|
||||
public string? UncertaintyBudgetDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class VerdictVerificationInfo
|
||||
{
|
||||
public bool VerdictFound { get; init; }
|
||||
public string? VerdictDigest { get; init; }
|
||||
public string? Decision { get; init; }
|
||||
public string? SbomDigest { get; init; }
|
||||
public string? FeedsDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class VerdictListItem
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public string? GraphRevisionId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictOciPublisherIntegrationTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Task: VERDICT-010
|
||||
// Description: Integration tests for verdict push with local OCI registry.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for VerdictOciPublisher using a real OCI registry (Distribution).
|
||||
/// These tests require Docker to be running.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class VerdictOciPublisherIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _registryContainer;
|
||||
private string _registryHost = string.Empty;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Start a local OCI Distribution registry container
|
||||
_registryContainer = new ContainerBuilder()
|
||||
.WithImage("registry:2")
|
||||
.WithPortBinding(5000, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5000))
|
||||
.Build();
|
||||
|
||||
await _registryContainer.StartAsync();
|
||||
|
||||
var port = _registryContainer.GetMappedPublicPort(5000);
|
||||
_registryHost = $"localhost:{port}";
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
if (_registryContainer is not null)
|
||||
{
|
||||
await _registryContainer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ToLocalRegistry_SuccessfullyPushesVerdict()
|
||||
{
|
||||
// Arrange
|
||||
// First, we need to push a base image that the verdict will reference
|
||||
var baseImageDigest = await PushBaseImageAsync();
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
_httpClient!,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = _registryHost },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var verdictEnvelope = CreateTestDsseEnvelope("pass");
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/test/app",
|
||||
ImageDigest = baseImageDigest,
|
||||
DsseEnvelopeBytes = verdictEnvelope,
|
||||
SbomDigest = "sha256:sbom123",
|
||||
FeedsDigest = "sha256:feeds456",
|
||||
PolicyDigest = "sha256:policy789",
|
||||
Decision = "pass",
|
||||
GraphRevisionId = "integration-test-rev-001",
|
||||
VerdictTimestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, $"Push failed: {result.Error}");
|
||||
Assert.NotNull(result.ManifestDigest);
|
||||
Assert.StartsWith("sha256:", result.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ToLocalRegistry_VerdictIsDiscoverableViaReferrersApi()
|
||||
{
|
||||
// Arrange
|
||||
var baseImageDigest = await PushBaseImageAsync();
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
_httpClient!,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = _registryHost },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/test/app",
|
||||
ImageDigest = baseImageDigest,
|
||||
DsseEnvelopeBytes = CreateTestDsseEnvelope("warn"),
|
||||
SbomDigest = "sha256:sbom_referrer_test",
|
||||
FeedsDigest = "sha256:feeds_referrer_test",
|
||||
PolicyDigest = "sha256:policy_referrer_test",
|
||||
Decision = "warn"
|
||||
};
|
||||
|
||||
// Act
|
||||
var pushResult = await verdictPublisher.PushAsync(request);
|
||||
Assert.True(pushResult.Success, $"Push failed: {pushResult.Error}");
|
||||
|
||||
// Query the referrers API
|
||||
var referrersUrl = $"http://{_registryHost}/v2/test/app/referrers/{baseImageDigest}";
|
||||
var referrersRequest = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
|
||||
referrersRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(referrersRequest);
|
||||
|
||||
// Assert
|
||||
Assert.True(response.IsSuccessStatusCode, $"Referrers API failed: {response.StatusCode}");
|
||||
|
||||
var referrersJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(referrersJson);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("manifests", out var manifests));
|
||||
Assert.True(manifests.GetArrayLength() > 0, "No referrers found");
|
||||
|
||||
// Find our verdict referrer
|
||||
var verdictFound = false;
|
||||
foreach (var manifest in manifests.EnumerateArray())
|
||||
{
|
||||
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
|
||||
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
|
||||
{
|
||||
verdictFound = true;
|
||||
|
||||
// Verify annotations
|
||||
Assert.True(manifest.TryGetProperty("annotations", out var annotations));
|
||||
Assert.Equal("warn", annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString());
|
||||
Assert.Equal("sha256:sbom_referrer_test", annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(verdictFound, "Verdict attestation not found in referrers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_MultipleTimes_CreatesSeparateReferrers()
|
||||
{
|
||||
// Arrange
|
||||
var baseImageDigest = await PushBaseImageAsync();
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
_httpClient!,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = _registryHost },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
// Act - Push two different verdicts
|
||||
var request1 = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/test/app",
|
||||
ImageDigest = baseImageDigest,
|
||||
DsseEnvelopeBytes = CreateTestDsseEnvelope("pass"),
|
||||
SbomDigest = "sha256:sbom_v1",
|
||||
FeedsDigest = "sha256:feeds_v1",
|
||||
PolicyDigest = "sha256:policy_v1",
|
||||
Decision = "pass"
|
||||
};
|
||||
|
||||
var request2 = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/test/app",
|
||||
ImageDigest = baseImageDigest,
|
||||
DsseEnvelopeBytes = CreateTestDsseEnvelope("block"),
|
||||
SbomDigest = "sha256:sbom_v2",
|
||||
FeedsDigest = "sha256:feeds_v2",
|
||||
PolicyDigest = "sha256:policy_v2",
|
||||
Decision = "block"
|
||||
};
|
||||
|
||||
var result1 = await verdictPublisher.PushAsync(request1);
|
||||
var result2 = await verdictPublisher.PushAsync(request2);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.NotEqual(result1.ManifestDigest, result2.ManifestDigest);
|
||||
|
||||
// Query referrers
|
||||
var referrersUrl = $"http://{_registryHost}/v2/test/app/referrers/{baseImageDigest}";
|
||||
var referrersRequest = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
|
||||
referrersRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(referrersRequest);
|
||||
var referrersJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(referrersJson);
|
||||
|
||||
var manifests = doc.RootElement.GetProperty("manifests");
|
||||
var verdictCount = manifests.EnumerateArray()
|
||||
.Count(m => m.TryGetProperty("artifactType", out var at) &&
|
||||
at.GetString() == OciMediaTypes.VerdictAttestation);
|
||||
|
||||
Assert.Equal(2, verdictCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_WithUncertaintyDigests_IncludesInAnnotations()
|
||||
{
|
||||
// Arrange - SPRINT_4300_0002_0002 integration
|
||||
var baseImageDigest = await PushBaseImageAsync();
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
_httpClient!,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = _registryHost },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = $"{_registryHost}/test/app",
|
||||
ImageDigest = baseImageDigest,
|
||||
DsseEnvelopeBytes = CreateTestDsseEnvelope("pass"),
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy",
|
||||
Decision = "pass",
|
||||
UncertaintyStatementDigest = "sha256:uncertainty_statement_digest",
|
||||
UncertaintyBudgetDigest = "sha256:uncertainty_budget_digest"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
|
||||
// Fetch the manifest and verify annotations
|
||||
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/{result.ManifestDigest}";
|
||||
var manifestRequest = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
|
||||
manifestRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
|
||||
|
||||
var response = await _httpClient!.SendAsync(manifestRequest);
|
||||
var manifestJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
|
||||
Assert.Equal("sha256:uncertainty_statement_digest",
|
||||
annotations.GetProperty(OciAnnotations.StellaUncertaintyDigest).GetString());
|
||||
Assert.Equal("sha256:uncertainty_budget_digest",
|
||||
annotations.GetProperty(OciAnnotations.StellaUncertaintyBudgetDigest).GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a minimal base image that verdicts can reference.
|
||||
/// </summary>
|
||||
private async Task<string> PushBaseImageAsync()
|
||||
{
|
||||
// Create a minimal OCI image config
|
||||
var config = """{"created":"2025-12-22T00:00:00Z","architecture":"amd64","os":"linux"}"""u8.ToArray();
|
||||
var configDigest = ComputeSha256Digest(config);
|
||||
|
||||
// Push config blob
|
||||
await PushBlobAsync("test/app", configDigest, config);
|
||||
|
||||
// Create manifest
|
||||
var manifest = $$"""
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.image.config.v1+json",
|
||||
"digest": "{{configDigest}}",
|
||||
"size": {{config.Length}}
|
||||
},
|
||||
"layers": []
|
||||
}
|
||||
""";
|
||||
|
||||
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifest);
|
||||
var manifestDigest = ComputeSha256Digest(manifestBytes);
|
||||
|
||||
// Push manifest
|
||||
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/{manifestDigest}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl);
|
||||
request.Content = new ByteArrayContent(manifestBytes);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
|
||||
|
||||
var response = await _httpClient!.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return manifestDigest;
|
||||
}
|
||||
|
||||
private async Task PushBlobAsync(string repository, string digest, byte[] content)
|
||||
{
|
||||
// Initiate upload
|
||||
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
|
||||
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
|
||||
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
|
||||
initiateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var uploadLocation = initiateResponse.Headers.Location?.ToString();
|
||||
Assert.NotNull(uploadLocation);
|
||||
|
||||
// Complete upload
|
||||
var separator = uploadLocation.Contains('?') ? "&" : "?";
|
||||
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
|
||||
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
uploadUrl = $"http://{_registryHost}{uploadUrl}";
|
||||
}
|
||||
|
||||
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
|
||||
uploadRequest.Content = new ByteArrayContent(content);
|
||||
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
|
||||
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
|
||||
uploadResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] content)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static byte[] CreateTestDsseEnvelope(string decision)
|
||||
{
|
||||
var payload = $$"""
|
||||
{
|
||||
"decision": "{{decision}}",
|
||||
"timestamp": "2025-12-22T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var envelope = $$"""
|
||||
{
|
||||
"payloadType": "verdict.stella/v1",
|
||||
"payload": "{{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload))}}",
|
||||
"signatures": []
|
||||
}
|
||||
""";
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(envelope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictOciPublisherTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Description: Tests for VerdictOciPublisher service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Tests;
|
||||
|
||||
public sealed class VerdictOciPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PushAsync_ValidRequest_PushesVerdictAsReferrer()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance,
|
||||
timeProvider: new FixedTimeProvider(new DateTimeOffset(2025, 12, 22, 10, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/myapp/container:v1",
|
||||
ImageDigest = "sha256:abc123def456",
|
||||
DsseEnvelopeBytes = """{"payloadType":"verdict.stella/v1","payload":"eyJ0ZXN0IjoidmVyZGljdCJ9","signatures":[]}"""u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom111",
|
||||
FeedsDigest = "sha256:feeds222",
|
||||
PolicyDigest = "sha256:policy333",
|
||||
Decision = "pass",
|
||||
GraphRevisionId = "rev-001",
|
||||
ProofBundleDigest = "sha256:proof444",
|
||||
VerdictTimestamp = new DateTimeOffset(2025, 12, 22, 10, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.ManifestDigest);
|
||||
Assert.StartsWith("sha256:", result.ManifestDigest);
|
||||
Assert.NotNull(result.ManifestReference);
|
||||
Assert.Contains("@sha256:", result.ManifestReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ValidRequest_IncludesCorrectArtifactType()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/myapp/container@sha256:abc123",
|
||||
ImageDigest = "sha256:abc123",
|
||||
DsseEnvelopeBytes = "{}"u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy",
|
||||
Decision = "pass"
|
||||
};
|
||||
|
||||
// Act
|
||||
await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.ManifestBytes);
|
||||
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("artifactType", out var artifactType));
|
||||
Assert.Equal(OciMediaTypes.VerdictAttestation, artifactType.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ValidRequest_IncludesSubjectReference()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
var imageDigest = "sha256:image_under_test_digest";
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/myapp/container",
|
||||
ImageDigest = imageDigest,
|
||||
DsseEnvelopeBytes = "{}"u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy",
|
||||
Decision = "block"
|
||||
};
|
||||
|
||||
// Act
|
||||
await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.ManifestBytes);
|
||||
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("subject", out var subject));
|
||||
Assert.True(subject.TryGetProperty("digest", out var digest));
|
||||
Assert.Equal(imageDigest, digest.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ValidRequest_IncludesVerdictAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/app",
|
||||
ImageDigest = "sha256:abc",
|
||||
DsseEnvelopeBytes = "{}"u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom_digest_value",
|
||||
FeedsDigest = "sha256:feeds_digest_value",
|
||||
PolicyDigest = "sha256:policy_digest_value",
|
||||
Decision = "warn",
|
||||
GraphRevisionId = "test-revision-id",
|
||||
ProofBundleDigest = "sha256:proof_bundle_value"
|
||||
};
|
||||
|
||||
// Act
|
||||
await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.ManifestBytes);
|
||||
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
|
||||
|
||||
Assert.Equal(VerdictPredicateTypes.Verdict,
|
||||
annotations.GetProperty(OciAnnotations.StellaPredicateType).GetString());
|
||||
Assert.Equal("sha256:sbom_digest_value",
|
||||
annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString());
|
||||
Assert.Equal("sha256:feeds_digest_value",
|
||||
annotations.GetProperty(OciAnnotations.StellaFeedsDigest).GetString());
|
||||
Assert.Equal("sha256:policy_digest_value",
|
||||
annotations.GetProperty(OciAnnotations.StellaPolicyDigest).GetString());
|
||||
Assert.Equal("warn",
|
||||
annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString());
|
||||
Assert.Equal("test-revision-id",
|
||||
annotations.GetProperty(OciAnnotations.StellaGraphRevisionId).GetString());
|
||||
Assert.Equal("sha256:proof_bundle_value",
|
||||
annotations.GetProperty(OciAnnotations.StellaProofBundleDigest).GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_OptionalFieldsNull_ExcludesFromAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/app",
|
||||
ImageDigest = "sha256:abc",
|
||||
DsseEnvelopeBytes = "{}"u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy",
|
||||
Decision = "pass",
|
||||
// Optional fields left null
|
||||
GraphRevisionId = null,
|
||||
ProofBundleDigest = null,
|
||||
AttestationDigest = null,
|
||||
VerdictTimestamp = null
|
||||
};
|
||||
|
||||
// Act
|
||||
await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.ManifestBytes);
|
||||
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
|
||||
|
||||
// Required fields should be present
|
||||
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaPredicateType, out _));
|
||||
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaSbomDigest, out _));
|
||||
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaFeedsDigest, out _));
|
||||
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaPolicyDigest, out _));
|
||||
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaVerdictDecision, out _));
|
||||
|
||||
// Optional fields should NOT be present
|
||||
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaGraphRevisionId, out _));
|
||||
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaProofBundleDigest, out _));
|
||||
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaAttestationDigest, out _));
|
||||
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaVerdictTimestamp, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_ValidRequest_LayerHasDsseMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new TestRegistryHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var pusher = new OciArtifactPusher(
|
||||
httpClient,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
new OciRegistryOptions { DefaultRegistry = "registry.example" },
|
||||
NullLogger<OciArtifactPusher>.Instance);
|
||||
|
||||
var verdictPublisher = new VerdictOciPublisher(pusher);
|
||||
|
||||
var request = new VerdictOciPublishRequest
|
||||
{
|
||||
Reference = "registry.example/app",
|
||||
ImageDigest = "sha256:abc",
|
||||
DsseEnvelopeBytes = """{"payloadType":"test","payload":"dGVzdA==","signatures":[]}"""u8.ToArray(),
|
||||
SbomDigest = "sha256:sbom",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
PolicyDigest = "sha256:policy",
|
||||
Decision = "pass"
|
||||
};
|
||||
|
||||
// Act
|
||||
await verdictPublisher.PushAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.ManifestBytes);
|
||||
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("layers", out var layers));
|
||||
Assert.Equal(1, layers.GetArrayLength());
|
||||
|
||||
var layer = layers[0];
|
||||
Assert.Equal(OciMediaTypes.DsseEnvelope, layer.GetProperty("mediaType").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictPredicateTypes_Verdict_MatchesExpectedUri()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("verdict.stella/v1", VerdictPredicateTypes.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OciMediaTypes_VerdictAttestation_HasCorrectFormat()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("application/vnd.stellaops.verdict.v1+json", OciMediaTypes.VerdictAttestation);
|
||||
}
|
||||
|
||||
private sealed class TestRegistryHandler : HttpMessageHandler
|
||||
{
|
||||
public byte[]? ManifestBytes { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
|
||||
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
|
||||
response.Headers.Location = new Uri("/v2/app/blobs/uploads/upload-id", UriKind.Relative);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Put && path.Contains("/blobs/uploads/", StringComparison.Ordinal))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
|
||||
{
|
||||
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _time;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset time) => _time = time;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ActionablesEndpointsTests.cs
|
||||
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
|
||||
// Description: Integration tests for actionables engine endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for actionables engine endpoints.
|
||||
/// </summary>
|
||||
public sealed class ActionablesEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeltaActionables_ValidDeltaId_ReturnsActionables()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("delta-12345678", result!.DeltaId);
|
||||
Assert.NotNull(result.Actionables);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeltaActionables_SortedByPriority()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
if (result!.Actionables.Count > 1)
|
||||
{
|
||||
var priorities = result.Actionables.Select(GetPriorityOrder).ToList();
|
||||
Assert.True(priorities.SequenceEqual(priorities.Order()));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActionablesByPriority_Critical_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/critical");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.All(result!.Actionables, a => Assert.Equal("critical", a.Priority, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActionablesByPriority_InvalidPriority_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/invalid");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActionablesByType_Upgrade_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/upgrade");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.All(result!.Actionables, a => Assert.Equal("upgrade", a.Type, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActionablesByType_Vex_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/vex");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.All(result!.Actionables, a => Assert.Equal("vex", a.Type, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActionablesByType_InvalidType_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/invalid");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeltaActionables_IncludesEstimatedEffort()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
|
||||
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
foreach (var actionable in result!.Actionables)
|
||||
{
|
||||
Assert.NotNull(actionable.EstimatedEffort);
|
||||
Assert.Contains(actionable.EstimatedEffort, new[] { "trivial", "low", "medium", "high" });
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetPriorityOrder(ActionableDto actionable)
|
||||
{
|
||||
return actionable.Priority.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 0,
|
||||
"high" => 1,
|
||||
"medium" => 2,
|
||||
"low" => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BaselineEndpointsTests.cs
|
||||
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
|
||||
// Description: Integration tests for baseline selection endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for baseline selection endpoints.
|
||||
/// </summary>
|
||||
public sealed class BaselineEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecommendations_ValidDigest_ReturnsRecommendations()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sha256:artifact123", result!.ArtifactDigest);
|
||||
Assert.NotEmpty(result.Recommendations);
|
||||
Assert.Contains(result.Recommendations, r => r.IsDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecommendations_WithEnvironment_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result!.Recommendations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecommendations_IncludesRationale()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
foreach (var rec in result!.Recommendations)
|
||||
{
|
||||
Assert.NotEmpty(rec.Rationale);
|
||||
Assert.NotEmpty(rec.Type);
|
||||
Assert.NotEmpty(rec.Label);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRationale_ValidDigests_ReturnsDetailedRationale()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:base123/sha256:head456");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRationaleResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sha256:base123", result!.BaseDigest);
|
||||
Assert.Equal("sha256:head456", result.HeadDigest);
|
||||
Assert.NotEmpty(result.SelectionType);
|
||||
Assert.NotEmpty(result.Rationale);
|
||||
Assert.NotEmpty(result.DetailedExplanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRationale_IncludesSelectionCriteria()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:baseline-base123/sha256:head456");
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRationaleResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.SelectionCriteria);
|
||||
Assert.NotEmpty(result.SelectionCriteria);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecommendations_DefaultIsFirst()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result!.Recommendations);
|
||||
Assert.True(result.Recommendations[0].IsDefault);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CounterfactualEndpointsTests.cs
|
||||
// Sprint: SPRINT_4200_0002_0005_counterfactuals
|
||||
// Description: Integration tests for counterfactual analysis endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for counterfactual analysis endpoints.
|
||||
/// </summary>
|
||||
public sealed class CounterfactualEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_ValidRequest_ReturnsCounterfactuals()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("finding-123", result!.FindingId);
|
||||
Assert.Equal("Block", result.CurrentVerdict);
|
||||
Assert.True(result.HasPaths);
|
||||
Assert.NotEmpty(result.Paths);
|
||||
Assert.NotEmpty(result.WouldPassIf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_MissingFindingId_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "",
|
||||
VulnId = "CVE-2021-44228"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_IncludesVexPath()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Vex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_IncludesReachabilityPath()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_IncludesExceptionPath()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_WithMaxPaths_LimitsResults()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
CurrentVerdict = "Block",
|
||||
MaxPaths = 2
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Paths.Count <= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetForFinding_ValidId_ReturnsCounterfactuals()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/counterfactuals/finding/finding-123");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("finding-123", result!.FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScanSummary_ValidId_ReturnsSummary()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualScanSummaryDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("scan-123", result!.ScanId);
|
||||
Assert.NotNull(result.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScanSummary_IncludesPathCounts()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualScanSummaryDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.TotalBlocked >= 0);
|
||||
Assert.True(result.WithVexPath >= 0);
|
||||
Assert.True(result.WithReachabilityPath >= 0);
|
||||
Assert.True(result.WithUpgradePath >= 0);
|
||||
Assert.True(result.WithExceptionPath >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompute_PathsHaveConditions()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new CounterfactualRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
VulnId = "CVE-2021-44228",
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
foreach (var path in result!.Paths)
|
||||
{
|
||||
Assert.NotEmpty(path.Description);
|
||||
Assert.NotEmpty(path.Conditions);
|
||||
foreach (var condition in path.Conditions)
|
||||
{
|
||||
Assert.NotEmpty(condition.Field);
|
||||
Assert.NotEmpty(condition.RequiredValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaCompareEndpointsTests.cs
|
||||
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
|
||||
// Description: Integration tests for delta compare endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for delta compare endpoints.
|
||||
/// </summary>
|
||||
public sealed class DeltaCompareEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompare_ValidRequest_ReturnsComparisonResult()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new DeltaCompareRequestDto
|
||||
{
|
||||
BaseDigest = "sha256:base123",
|
||||
TargetDigest = "sha256:target456",
|
||||
IncludeVulnerabilities = true,
|
||||
IncludeComponents = true,
|
||||
IncludePolicyDiff = true
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Base);
|
||||
Assert.NotNull(result.Target);
|
||||
Assert.NotNull(result.Summary);
|
||||
Assert.NotEmpty(result.ComparisonId);
|
||||
Assert.Equal("sha256:base123", result.Base.Digest);
|
||||
Assert.Equal("sha256:target456", result.Target.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompare_MissingBaseDigest_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new DeltaCompareRequestDto
|
||||
{
|
||||
BaseDigest = "",
|
||||
TargetDigest = "sha256:target456"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompare_MissingTargetDigest_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new DeltaCompareRequestDto
|
||||
{
|
||||
BaseDigest = "sha256:base123",
|
||||
TargetDigest = ""
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuickDiff_ValidDigests_ReturnsQuickSummary()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123&targetDigest=sha256:target456");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuickDiffSummaryDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sha256:base123", result!.BaseDigest);
|
||||
Assert.Equal("sha256:target456", result.TargetDigest);
|
||||
Assert.NotEmpty(result.RiskDirection);
|
||||
Assert.NotEmpty(result.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuickDiff_MissingDigest_ReturnsBadRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetComparison_NotFound_ReturnsNotFound()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/delta/nonexistent-id");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostCompare_DeterministicComparisonId_SameInputsSameId()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new DeltaCompareRequestDto
|
||||
{
|
||||
BaseDigest = "sha256:base123",
|
||||
TargetDigest = "sha256:target456"
|
||||
};
|
||||
|
||||
var response1 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
var result1 = await response1.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
|
||||
|
||||
var response2 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
var result2 = await response2.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result1);
|
||||
Assert.NotNull(result2);
|
||||
Assert.Equal(result1!.ComparisonId, result2!.ComparisonId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TriageStatusEndpointsTests.cs
|
||||
// Sprint: SPRINT_4200_0001_0001_triage_rest_api
|
||||
// Description: Integration tests for triage status endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for triage status endpoints.
|
||||
/// </summary>
|
||||
public sealed class TriageStatusEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetFindingStatus_NotFound_ReturnsNotFound()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/findings/nonexistent-finding");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUpdateStatus_ValidRequest_ReturnsUpdatedStatus()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new UpdateTriageStatusRequestDto
|
||||
{
|
||||
Lane = "MutedVex",
|
||||
DecisionKind = "VexNotAffected",
|
||||
Reason = "Vendor confirms not affected"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/findings/finding-123/status", request);
|
||||
// Note: Will return 404 since finding doesn't exist in test context
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostVexStatement_ValidRequest_ReturnsResponse()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new SubmitVexStatementRequestDto
|
||||
{
|
||||
Status = "NotAffected",
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
ImpactStatement = "Code path analysis shows vulnerability is not reachable"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/findings/finding-123/vex", request);
|
||||
// Note: Will return 404 since finding doesn't exist in test context
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_EmptyFilters_ReturnsResults()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new BulkTriageQueryRequestDto
|
||||
{
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Findings);
|
||||
Assert.NotNull(result.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_WithLaneFilter_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new BulkTriageQueryRequestDto
|
||||
{
|
||||
Lanes = ["Active", "Blocked"],
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_WithVerdictFilter_FiltersCorrectly()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new BulkTriageQueryRequestDto
|
||||
{
|
||||
Verdicts = ["Block"],
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_ValidDigest_ReturnsSummary()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.ByLane);
|
||||
Assert.NotNull(result.ByVerdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_IncludesAllLanes()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var expectedLanes = new[] { "Active", "Blocked", "NeedsException", "MutedReach", "MutedVex", "Compensated" };
|
||||
foreach (var lane in expectedLanes)
|
||||
{
|
||||
Assert.True(result!.ByLane.ContainsKey(lane), $"Expected lane '{lane}' to be present");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_IncludesAllVerdicts()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var expectedVerdicts = new[] { "Ship", "Block", "Exception" };
|
||||
foreach (var verdict in expectedVerdicts)
|
||||
{
|
||||
Assert.True(result!.ByVerdict.ContainsKey(verdict), $"Expected verdict '{verdict}' to be present");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_ResponseIncludesSummary()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new BulkTriageQueryRequestDto
|
||||
{
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Summary);
|
||||
Assert.True(result.Summary.CanShipCount >= 0);
|
||||
Assert.True(result.Summary.BlockingCount >= 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user