save work

This commit is contained in:
StellaOps Bot
2025-12-19 09:40:41 +02:00
parent 2eafe98d44
commit 43882078a4
44 changed files with 3044 additions and 492 deletions

View File

@@ -85,8 +85,21 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
var payloadBase64 = Convert.ToBase64String(statementJson);
// Sign the payload
string? tenantId = request.TenantId;
if (string.IsNullOrWhiteSpace(tenantId) && _signerClient is not null && options.UseSignerService)
{
return new PolicyDecisionAttestationResult
{
Success = false,
Error = "TenantId is required when using the signer service"
};
}
tenantId ??= "unknown";
string? attestationDigest;
string? keyId;
VexDsseSignature signature;
if (_signerClient is not null && options.UseSignerService)
{
@@ -96,7 +109,7 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
PayloadType = PredicateTypes.StellaOpsPolicyDecision,
PayloadBase64 = payloadBase64,
KeyId = request.KeyId ?? options.DefaultKeyId,
TenantId = request.TenantId
TenantId = tenantId
},
cancellationToken).ConfigureAwait(false);
@@ -110,25 +123,39 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
};
}
// Compute attestation digest from signed payload
attestationDigest = ComputeDigest(statementJson);
keyId = signResult.KeyId;
signature = new VexDsseSignature
{
KeyId = signResult.KeyId,
Sig = signResult.Signature!
};
}
else
{
// Create unsigned attestation (dev/test mode)
attestationDigest = ComputeDigest(statementJson);
// Create locally-signed envelope (dev/test mode; placeholder signature).
keyId = null;
_logger.LogDebug("Created unsigned attestation (signer service not available)");
signature = SignLocally(PredicateTypes.StellaOpsPolicyDecision, statementJson);
_logger.LogDebug("Created locally-signed attestation (signer service not available)");
}
var envelope = new VexDsseEnvelope
{
PayloadType = PredicateTypes.StellaOpsPolicyDecision,
Payload = payloadBase64,
Signatures = [signature]
};
var envelopeJson = SerializeCanonical(envelope);
var envelopeDigestHex = Convert.ToHexString(SHA256.HashData(envelopeJson)).ToLowerInvariant();
attestationDigest = $"sha256:{envelopeDigestHex}";
// Submit to Rekor if requested
RekorSubmissionResult? rekorResult = null;
var shouldSubmitToRekor = request.SubmitToRekor || options.SubmitToRekorByDefault;
if (shouldSubmitToRekor && attestationDigest is not null)
{
rekorResult = await SubmitToRekorAsync(attestationDigest, cancellationToken)
rekorResult = await SubmitEnvelopeToRekorAsync(envelope, envelopeDigestHex, request, cancellationToken)
.ConfigureAwait(false);
if (!rekorResult.Success)
@@ -266,6 +293,99 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions);
}
private static VexDsseSignature SignLocally(string payloadType, byte[] payload)
{
// DSSE PAE: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
var prefix = "DSSEv1 "u8;
writer.Write(prefix);
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
writer.Write(typeBytes.Length.ToString());
writer.Write(' ');
writer.Write(typeBytes);
writer.Write(' ');
writer.Write(payload.Length.ToString());
writer.Write(' ');
writer.Write(payload);
var pae = ms.ToArray();
var signatureBytes = SHA256.HashData(pae);
return new VexDsseSignature
{
Sig = Convert.ToBase64String(signatureBytes)
};
}
private async Task<RekorSubmissionResult> SubmitEnvelopeToRekorAsync(
VexDsseEnvelope envelope,
string envelopeDigestHex,
PolicyDecisionAttestationRequest request,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
return new RekorSubmissionResult
{
Success = false,
Error = "Rekor client not available"
};
}
var subjectUris = request.Subjects
.OrderBy(static x => x.Name, StringComparer.Ordinal)
.Select(static subject =>
{
var digest = subject.Digest
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.Select(static kvp => $"{kvp.Key}:{kvp.Value}")
.FirstOrDefault();
return digest is null ? subject.Name : $"{subject.Name}@{digest}";
})
.ToList();
var submitResult = await _rekorClient.SubmitAsync(
new VexRekorSubmitRequest
{
Envelope = envelope,
EnvelopeDigest = envelopeDigestHex,
ArtifactKind = "policy-decision",
SubjectUris = subjectUris
},
cancellationToken).ConfigureAwait(false);
if (!submitResult.Success)
{
return new RekorSubmissionResult
{
Success = false,
Error = submitResult.Error ?? "Rekor submission failed"
};
}
if (submitResult.Metadata is null)
{
return new RekorSubmissionResult
{
Success = false,
Error = "Rekor submission succeeded but no metadata was returned"
};
}
return new RekorSubmissionResult
{
Success = true,
LogIndex = submitResult.Metadata.Index,
Uuid = submitResult.Metadata.Uuid,
IntegratedTime = submitResult.Metadata.IntegratedAt
};
}
private static string ComputeDigest(byte[] data)
{
var hash = SHA256.HashData(data);

View File

@@ -184,7 +184,8 @@ public sealed class ProofAwareScoringEngine : IScoringEngine
using var sha256 = System.Security.Cryptography.SHA256.Create();
var inputString = $"{input.FindingId}:{input.TenantId}:{input.ProfileId}:{input.AsOf:O}";
foreach (var kvp in input.InputDigests?.OrderBy(x => x.Key) ?? [])
foreach (var kvp in input.InputDigests?.OrderBy(x => x.Key)
?? Enumerable.Empty<System.Collections.Generic.KeyValuePair<string, string>>())
{
inputString += $":{kvp.Key}={kvp.Value}";
}

View File

@@ -57,7 +57,7 @@ public sealed class ScoringEngineFactory : IScoringEngineFactory
/// </summary>
public IScoringEngine GetEngine(ScoringProfile profile)
{
var engine = profile switch
IScoringEngine engine = profile switch
{
ScoringProfile.Simple => _services.GetRequiredService<SimpleScoringEngine>(),
ScoringProfile.Advanced => _services.GetRequiredService<AdvancedScoringEngine>(),

View File

@@ -6,3 +6,4 @@ This file mirrors sprint work for the Policy Engine module.
| --- | --- | --- | --- |
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented PolicyGateEvaluator (lattice/uncertainty/evidence completeness) and aligned tests/docs; see `src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs` and `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/PolicyGateEvaluatorTests.cs`. |
| `DET-3401-011` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `Explain` to `RiskScoringResult` and covered JSON serialization + null-coercion in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Scoring/RiskScoringResultTests.cs`. |
| `PDA-3801-0001` | `docs/implplan/SPRINT_3801_0001_0001_policy_decision_attestation.md` | DONE (2025-12-19) | Implemented `PolicyDecisionAttestationService` + predicate model + DI wiring; covered signer/Rekor flows in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Attestation/PolicyDecisionAttestationServiceTests.cs`. |

View File

@@ -67,10 +67,10 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123",
Signature = "AQID",
KeyId = "key-1"
});
@@ -81,7 +81,8 @@ public class PolicyDecisionAttestationServiceTests
// Assert
Assert.True(result.Success);
Assert.Equal("sha256:abc123", result.AttestationDigest);
Assert.NotNull(result.AttestationDigest);
Assert.Matches("^sha256:[a-f0-9]{64}$", result.AttestationDigest);
Assert.Equal("key-1", result.KeyId);
_signerClientMock.Verify(x => x.SignAsync(
@@ -97,7 +98,7 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = false,
Error = "Key not found"
@@ -120,21 +121,26 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123",
Signature = "AQID",
KeyId = "key-1"
});
_rekorClientMock.Setup(x => x.SubmitAsync(
It.IsAny<string>(),
It.IsAny<VexRekorSubmitRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorResponse
.ReturnsAsync(new VexRekorSubmitResult
{
Success = true,
LogIndex = 12345,
Uuid = "rekor-uuid-123"
Metadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.local/api/v1/log/entries/rekor-uuid-123",
IntegratedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
}
});
var request = CreateTestRequest() with { SubmitToRekor = true };
@@ -147,9 +153,16 @@ public class PolicyDecisionAttestationServiceTests
Assert.NotNull(result.RekorResult);
Assert.True(result.RekorResult.Success);
Assert.Equal(12345, result.RekorResult.LogIndex);
Assert.Equal("rekor-uuid-123", result.RekorResult.Uuid);
var envelopeDigestHex = result.AttestationDigest!.Substring("sha256:".Length);
_rekorClientMock.Verify(x => x.SubmitAsync(
"sha256:abc123",
It.Is<VexRekorSubmitRequest>(r =>
r.ArtifactKind == "policy-decision" &&
r.Envelope.PayloadType == PredicateTypes.StellaOpsPolicyDecision &&
r.EnvelopeDigest == envelopeDigestHex &&
r.SubjectUris!.Contains("example.com/image:v1@sha256:abc123")),
It.IsAny<CancellationToken>()),
Times.Once);
}
@@ -183,10 +196,10 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123"
Signature = "AQID"
});
var request = CreateTestRequest() with
@@ -306,7 +319,8 @@ public class PolicyDecisionAttestationServiceTests
Name = "example.com/image:v1",
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
}
},
TenantId = "tenant-1"
};
}
}

View File

@@ -55,8 +55,8 @@ public sealed class ScorePolicyServiceCachingTests
var result2 = _service.GetPolicy("tenant-2");
result1.Should().NotBeSameAs(result2);
result1.PolicyId.Should().Be("tenant-1");
result2.PolicyId.Should().Be("tenant-2");
result1.Should().BeSameAs(policy1);
result2.Should().BeSameAs(policy2);
_providerMock.Verify(p => p.GetPolicy("tenant-1"), Times.Once());
_providerMock.Verify(p => p.GetPolicy("tenant-2"), Times.Once());
}
@@ -193,7 +193,7 @@ public sealed class ScorePolicyServiceCachingTests
var policy1 = new ScorePolicy
{
PolicyVersion = "score.v1",
PolicyId = "stable-test",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
@@ -206,7 +206,7 @@ public sealed class ScorePolicyServiceCachingTests
var policy2 = new ScorePolicy
{
PolicyVersion = "score.v1",
PolicyId = "stable-test",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
@@ -225,12 +225,11 @@ public sealed class ScorePolicyServiceCachingTests
private static ScorePolicy CreateTestPolicy(string id) => new()
{
PolicyVersion = "score.v1",
PolicyId = id,
PolicyName = $"Test Policy {id}",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
Reachability = 2500,
BaseSeverity = id.EndsWith("2", StringComparison.Ordinal) ? 2400 : 2500,
Reachability = id.EndsWith("2", StringComparison.Ordinal) ? 2600 : 2500,
Evidence = 2500,
Provenance = 2500
}

View File

@@ -199,7 +199,13 @@ public sealed class SimpleScoringEngineTests
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
Types = new HashSet<EvidenceType>
{
EvidenceType.Runtime,
EvidenceType.Dast,
EvidenceType.Sast,
EvidenceType.Sca
},
NewestEvidenceAt = asOf
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
@@ -220,7 +226,13 @@ public sealed class SimpleScoringEngineTests
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
Types = new HashSet<EvidenceType>
{
EvidenceType.Runtime,
EvidenceType.Dast,
EvidenceType.Sast,
EvidenceType.Sca
},
NewestEvidenceAt = DateTimeOffset.UtcNow
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
@@ -311,7 +323,16 @@ public sealed class SimpleScoringEngineTests
]
};
var input = CreateInput(cvss: 10.0m, hopCount: null);
var asOf = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var input = CreateInput(cvss: 10.0m, hopCount: null, asOf: asOf) with
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
NewestEvidenceAt = asOf
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
};
var result = await _engine.ScoreAsync(input, policy);