672 lines
24 KiB
C#
672 lines
24 KiB
C#
// -----------------------------------------------------------------------------
|
|
// AttestationChainVerifier.cs
|
|
// Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-003)
|
|
// Description: Verifies attestation chain integrity.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Domain;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace StellaOps.Scanner.WebService.Services;
|
|
|
|
/// <summary>
|
|
/// Verifies attestation chain integrity.
|
|
/// </summary>
|
|
public sealed class AttestationChainVerifier : IAttestationChainVerifier
|
|
{
|
|
private readonly ILogger<AttestationChainVerifier> _logger;
|
|
private readonly AttestationChainVerifierOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly IPolicyDecisionAttestationService _policyAttestationService;
|
|
private readonly IRichGraphAttestationService _richGraphAttestationService;
|
|
private readonly IHumanApprovalAttestationService _humanApprovalAttestationService;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of <see cref="AttestationChainVerifier"/>.
|
|
/// </summary>
|
|
public AttestationChainVerifier(
|
|
ILogger<AttestationChainVerifier> logger,
|
|
IOptions<AttestationChainVerifierOptions> options,
|
|
TimeProvider timeProvider,
|
|
IPolicyDecisionAttestationService policyAttestationService,
|
|
IRichGraphAttestationService richGraphAttestationService,
|
|
IHumanApprovalAttestationService humanApprovalAttestationService)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
_policyAttestationService = policyAttestationService ?? throw new ArgumentNullException(nameof(policyAttestationService));
|
|
_richGraphAttestationService = richGraphAttestationService ?? throw new ArgumentNullException(nameof(richGraphAttestationService));
|
|
_humanApprovalAttestationService = humanApprovalAttestationService ?? throw new ArgumentNullException(nameof(humanApprovalAttestationService));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ChainVerificationResult> VerifyChainAsync(
|
|
ChainVerificationInput input,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(input);
|
|
|
|
if (string.IsNullOrWhiteSpace(input.FindingId))
|
|
{
|
|
throw new ArgumentException("FindingId is required", nameof(input));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(input.RootDigest))
|
|
{
|
|
throw new ArgumentException("RootDigest is required", nameof(input));
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Verifying attestation chain for scan {ScanId}, finding {FindingId}",
|
|
input.ScanId,
|
|
input.FindingId);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var details = new List<AttestationVerificationDetail>();
|
|
var attestations = new List<ChainAttestation>();
|
|
var now = _timeProvider.GetUtcNow();
|
|
var hasFailures = false;
|
|
var hasExpired = false;
|
|
|
|
// Collect attestations in chain order
|
|
// 1. RichGraph attestation (reachability analysis)
|
|
var richGraphResult = await VerifyRichGraphAttestationAsync(
|
|
input.ScanId,
|
|
input.FindingId,
|
|
now,
|
|
input.ExpirationGracePeriod,
|
|
cancellationToken);
|
|
|
|
if (richGraphResult.Detail != null)
|
|
{
|
|
details.Add(richGraphResult.Detail);
|
|
}
|
|
|
|
if (richGraphResult.Attestation != null)
|
|
{
|
|
attestations.Add(richGraphResult.Attestation);
|
|
}
|
|
|
|
hasFailures |= richGraphResult.IsFailed;
|
|
hasExpired |= richGraphResult.IsExpired;
|
|
|
|
// 2. Policy decision attestation
|
|
var policyResult = await VerifyPolicyAttestationAsync(
|
|
input.ScanId,
|
|
input.FindingId,
|
|
now,
|
|
input.ExpirationGracePeriod,
|
|
cancellationToken);
|
|
|
|
if (policyResult.Detail != null)
|
|
{
|
|
details.Add(policyResult.Detail);
|
|
}
|
|
|
|
if (policyResult.Attestation != null)
|
|
{
|
|
attestations.Add(policyResult.Attestation);
|
|
}
|
|
|
|
hasFailures |= policyResult.IsFailed;
|
|
hasExpired |= policyResult.IsExpired;
|
|
|
|
// 3. Human approval attestation
|
|
var humanApprovalResult = await VerifyHumanApprovalAttestationAsync(
|
|
input.ScanId,
|
|
input.FindingId,
|
|
now,
|
|
input.ExpirationGracePeriod,
|
|
cancellationToken);
|
|
|
|
if (humanApprovalResult.Detail != null)
|
|
{
|
|
details.Add(humanApprovalResult.Detail);
|
|
}
|
|
|
|
if (humanApprovalResult.Attestation != null)
|
|
{
|
|
attestations.Add(humanApprovalResult.Attestation);
|
|
}
|
|
|
|
hasFailures |= humanApprovalResult.IsFailed;
|
|
hasExpired |= humanApprovalResult.IsExpired;
|
|
|
|
stopwatch.Stop();
|
|
|
|
// Determine chain status
|
|
var chainStatus = DetermineChainStatus(
|
|
attestations,
|
|
hasFailures,
|
|
hasExpired,
|
|
input.RequiredTypes,
|
|
input.RequireHumanApproval);
|
|
|
|
// Build the chain
|
|
var chain = new AttestationChain
|
|
{
|
|
ChainId = ComputeChainId(input.ScanId, input.FindingId, input.RootDigest),
|
|
ScanId = input.ScanId.ToString(),
|
|
FindingId = input.FindingId,
|
|
RootDigest = input.RootDigest,
|
|
Attestations = attestations.ToImmutableList(),
|
|
Verified = chainStatus == ChainStatus.Complete,
|
|
VerifiedAt = now,
|
|
Status = chainStatus,
|
|
ExpiresAt = GetEarliestExpiration(attestations)
|
|
};
|
|
|
|
_logger.LogInformation(
|
|
"Chain verification completed in {ElapsedMs}ms: {Status} with {Count} attestations",
|
|
stopwatch.ElapsedMilliseconds,
|
|
chainStatus,
|
|
attestations.Count);
|
|
|
|
if (chainStatus == ChainStatus.Complete)
|
|
{
|
|
return ChainVerificationResult.Succeeded(chain, details);
|
|
}
|
|
|
|
var errorMessage = chainStatus switch
|
|
{
|
|
ChainStatus.Expired => "One or more attestations have expired",
|
|
ChainStatus.Invalid => "Signature verification failed or attestation revoked",
|
|
ChainStatus.Broken => "Chain link broken or digest mismatch",
|
|
ChainStatus.Partial => "Required attestations are missing",
|
|
ChainStatus.Empty => "No attestations found in chain",
|
|
_ => "Chain verification failed"
|
|
};
|
|
|
|
// Include details in failure result so callers can inspect why it failed
|
|
return new ChainVerificationResult
|
|
{
|
|
Success = false,
|
|
Chain = chain,
|
|
Error = errorMessage,
|
|
Details = details
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<AttestationChain?> GetChainAsync(
|
|
ScanId scanId,
|
|
string findingId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(findingId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var attestations = new List<ChainAttestation>();
|
|
var now = _timeProvider.GetUtcNow();
|
|
|
|
// Collect attestations (without full verification)
|
|
// Note: This is a simplified implementation; in production we'd have a more
|
|
// efficient way to query attestations by finding ID
|
|
|
|
// For now, we return null since we don't have a lookup by finding ID
|
|
// The full implementation would query attestation stores
|
|
_logger.LogDebug(
|
|
"GetChainAsync called for scan {ScanId}, finding {FindingId}",
|
|
scanId,
|
|
findingId);
|
|
|
|
// Placeholder: return null until we have proper attestation indexing
|
|
await Task.CompletedTask;
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool IsChainComplete(AttestationChain chain, params AttestationType[] requiredTypes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chain);
|
|
|
|
if (requiredTypes.Length == 0)
|
|
{
|
|
return chain.Attestations.Count > 0;
|
|
}
|
|
|
|
var presentTypes = chain.Attestations
|
|
.Where(a => a.Verified)
|
|
.Select(a => a.Type)
|
|
.ToHashSet();
|
|
|
|
return requiredTypes.All(t => presentTypes.Contains(t));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DateTimeOffset? GetEarliestExpiration(AttestationChain chain)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chain);
|
|
return GetEarliestExpiration(chain.Attestations);
|
|
}
|
|
|
|
private static DateTimeOffset? GetEarliestExpiration(IEnumerable<ChainAttestation> attestations)
|
|
{
|
|
var expirations = attestations
|
|
.Where(a => a.Verified)
|
|
.Select(a => a.ExpiresAt)
|
|
.ToList();
|
|
|
|
return expirations.Count > 0 ? expirations.Min() : null;
|
|
}
|
|
|
|
private async Task<AttestationVerificationResult> VerifyRichGraphAttestationAsync(
|
|
ScanId scanId,
|
|
string findingId,
|
|
DateTimeOffset now,
|
|
TimeSpan gracePeriod,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
// Try to get the RichGraph attestation
|
|
// Note: We use the finding ID as the graph ID for lookup
|
|
// In practice, we'd have a mapping from finding to graph
|
|
var attestation = await _richGraphAttestationService.GetAttestationAsync(
|
|
scanId,
|
|
findingId,
|
|
cancellationToken);
|
|
|
|
stopwatch.Stop();
|
|
|
|
if (attestation == null)
|
|
{
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.RichGraph,
|
|
AttestationId = "not-found",
|
|
Status = AttestationVerificationStatus.NotFound,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = "RichGraph attestation not found"
|
|
},
|
|
IsFailed = false, // Not found is partial, not failed
|
|
IsExpired = false
|
|
};
|
|
}
|
|
|
|
var statement = attestation.Statement!;
|
|
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7);
|
|
var isExpired = now > expiresAt.Add(gracePeriod);
|
|
|
|
var chainAttestation = new ChainAttestation
|
|
{
|
|
Type = AttestationType.RichGraph,
|
|
AttestationId = attestation.AttestationId!,
|
|
CreatedAt = statement.Predicate.ComputedAt,
|
|
ExpiresAt = expiresAt,
|
|
Verified = !isExpired,
|
|
VerificationStatus = isExpired
|
|
? AttestationVerificationStatus.Expired
|
|
: AttestationVerificationStatus.Valid,
|
|
SubjectDigest = statement.Predicate.GraphDigest,
|
|
PredicateType = statement.PredicateType
|
|
};
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Attestation = chainAttestation,
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.RichGraph,
|
|
AttestationId = attestation.AttestationId!,
|
|
Status = chainAttestation.VerificationStatus,
|
|
Verified = chainAttestation.Verified,
|
|
VerificationTime = stopwatch.Elapsed
|
|
},
|
|
IsFailed = false,
|
|
IsExpired = isExpired
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
_logger.LogWarning(ex, "Failed to verify RichGraph attestation for scan {ScanId}", scanId);
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.RichGraph,
|
|
AttestationId = "error",
|
|
Status = AttestationVerificationStatus.ChainBroken,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = ex.Message
|
|
},
|
|
IsFailed = true,
|
|
IsExpired = false
|
|
};
|
|
}
|
|
}
|
|
|
|
private async Task<AttestationVerificationResult> VerifyPolicyAttestationAsync(
|
|
ScanId scanId,
|
|
string findingId,
|
|
DateTimeOffset now,
|
|
TimeSpan gracePeriod,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
// Try to get the policy attestation
|
|
var attestation = await _policyAttestationService.GetAttestationAsync(
|
|
scanId,
|
|
findingId,
|
|
cancellationToken);
|
|
|
|
stopwatch.Stop();
|
|
|
|
if (attestation == null)
|
|
{
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.PolicyDecision,
|
|
AttestationId = "not-found",
|
|
Status = AttestationVerificationStatus.NotFound,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = "Policy decision attestation not found"
|
|
},
|
|
IsFailed = false, // Not found is partial, not failed
|
|
IsExpired = false
|
|
};
|
|
}
|
|
|
|
var statement = attestation.Statement!;
|
|
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7);
|
|
var isExpired = now > expiresAt.Add(gracePeriod);
|
|
|
|
var chainAttestation = new ChainAttestation
|
|
{
|
|
Type = AttestationType.PolicyDecision,
|
|
AttestationId = attestation.AttestationId!,
|
|
CreatedAt = statement.Predicate.EvaluatedAt,
|
|
ExpiresAt = expiresAt,
|
|
Verified = !isExpired,
|
|
VerificationStatus = isExpired
|
|
? AttestationVerificationStatus.Expired
|
|
: AttestationVerificationStatus.Valid,
|
|
SubjectDigest = statement.Subject[0].Digest["sha256"],
|
|
PredicateType = statement.PredicateType
|
|
};
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Attestation = chainAttestation,
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.PolicyDecision,
|
|
AttestationId = attestation.AttestationId!,
|
|
Status = chainAttestation.VerificationStatus,
|
|
Verified = chainAttestation.Verified,
|
|
VerificationTime = stopwatch.Elapsed
|
|
},
|
|
IsFailed = false,
|
|
IsExpired = isExpired
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
_logger.LogWarning(ex, "Failed to verify policy attestation for scan {ScanId}", scanId);
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.PolicyDecision,
|
|
AttestationId = "error",
|
|
Status = AttestationVerificationStatus.ChainBroken,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = ex.Message
|
|
},
|
|
IsFailed = true,
|
|
IsExpired = false
|
|
};
|
|
}
|
|
}
|
|
|
|
private async Task<AttestationVerificationResult> VerifyHumanApprovalAttestationAsync(
|
|
ScanId scanId,
|
|
string findingId,
|
|
DateTimeOffset now,
|
|
TimeSpan gracePeriod,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
// Try to get the human approval attestation
|
|
var attestation = await _humanApprovalAttestationService.GetAttestationAsync(
|
|
scanId,
|
|
findingId,
|
|
cancellationToken);
|
|
|
|
stopwatch.Stop();
|
|
|
|
if (attestation == null)
|
|
{
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.HumanApproval,
|
|
AttestationId = "not-found",
|
|
Status = AttestationVerificationStatus.NotFound,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = "Human approval attestation not found"
|
|
},
|
|
IsFailed = false, // Not found is partial, not failed
|
|
IsExpired = false
|
|
};
|
|
}
|
|
|
|
// Check if attestation was revoked
|
|
if (attestation.IsRevoked)
|
|
{
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.HumanApproval,
|
|
AttestationId = attestation.AttestationId!,
|
|
Status = AttestationVerificationStatus.Revoked,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = "Human approval attestation has been revoked"
|
|
},
|
|
IsFailed = true,
|
|
IsExpired = false
|
|
};
|
|
}
|
|
|
|
var statement = attestation.Statement!;
|
|
|
|
// Default to 30 days (human approval default TTL) if not specified
|
|
var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(30);
|
|
var isExpired = now > expiresAt.Add(gracePeriod);
|
|
|
|
// Get subject digest if available
|
|
var subjectDigest = statement.Subject.Count > 0
|
|
&& statement.Subject[0].Digest.TryGetValue("sha256", out var digest)
|
|
? digest
|
|
: string.Empty;
|
|
|
|
var chainAttestation = new ChainAttestation
|
|
{
|
|
Type = AttestationType.HumanApproval,
|
|
AttestationId = attestation.AttestationId!,
|
|
CreatedAt = statement.Predicate.ApprovedAt,
|
|
ExpiresAt = expiresAt,
|
|
Verified = !isExpired,
|
|
VerificationStatus = isExpired
|
|
? AttestationVerificationStatus.Expired
|
|
: AttestationVerificationStatus.Valid,
|
|
SubjectDigest = subjectDigest,
|
|
PredicateType = statement.PredicateType
|
|
};
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Attestation = chainAttestation,
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.HumanApproval,
|
|
AttestationId = attestation.AttestationId!,
|
|
Status = chainAttestation.VerificationStatus,
|
|
Verified = chainAttestation.Verified,
|
|
VerificationTime = stopwatch.Elapsed
|
|
},
|
|
IsFailed = false,
|
|
IsExpired = isExpired
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
_logger.LogWarning(ex, "Failed to verify human approval attestation for scan {ScanId}", scanId);
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Detail = new AttestationVerificationDetail
|
|
{
|
|
Type = AttestationType.HumanApproval,
|
|
AttestationId = "error",
|
|
Status = AttestationVerificationStatus.ChainBroken,
|
|
Verified = false,
|
|
VerificationTime = stopwatch.Elapsed,
|
|
Error = ex.Message
|
|
},
|
|
IsFailed = true,
|
|
IsExpired = false
|
|
};
|
|
}
|
|
}
|
|
|
|
private static ChainStatus DetermineChainStatus(
|
|
List<ChainAttestation> attestations,
|
|
bool hasFailures,
|
|
bool hasExpired,
|
|
IReadOnlyList<AttestationType>? requiredTypes,
|
|
bool requireHumanApproval)
|
|
{
|
|
if (hasFailures)
|
|
{
|
|
return ChainStatus.Invalid;
|
|
}
|
|
|
|
if (attestations.Count == 0)
|
|
{
|
|
return ChainStatus.Empty;
|
|
}
|
|
|
|
if (hasExpired)
|
|
{
|
|
return ChainStatus.Expired;
|
|
}
|
|
|
|
// Check for broken chain (digest mismatches would be detected during verification)
|
|
if (attestations.Any(a => a.VerificationStatus == AttestationVerificationStatus.ChainBroken))
|
|
{
|
|
return ChainStatus.Broken;
|
|
}
|
|
|
|
// Check for required types
|
|
var presentTypes = attestations
|
|
.Where(a => a.Verified)
|
|
.Select(a => a.Type)
|
|
.ToHashSet();
|
|
|
|
if (requiredTypes != null && requiredTypes.Count > 0)
|
|
{
|
|
if (!requiredTypes.All(t => presentTypes.Contains(t)))
|
|
{
|
|
return ChainStatus.Partial;
|
|
}
|
|
}
|
|
|
|
if (requireHumanApproval && !presentTypes.Contains(AttestationType.HumanApproval))
|
|
{
|
|
return ChainStatus.Partial;
|
|
}
|
|
|
|
// All verified attestations present
|
|
return ChainStatus.Complete;
|
|
}
|
|
|
|
private static string ComputeChainId(ScanId scanId, string findingId, string rootDigest)
|
|
{
|
|
var input = $"{scanId}|{findingId}|{rootDigest}";
|
|
return ComputeSha256(input);
|
|
}
|
|
|
|
private static string ComputeSha256(string input)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = SHA256.HashData(bytes);
|
|
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
|
}
|
|
|
|
private sealed record AttestationVerificationResult
|
|
{
|
|
public ChainAttestation? Attestation { get; init; }
|
|
public AttestationVerificationDetail? Detail { get; init; }
|
|
public bool IsFailed { get; init; }
|
|
public bool IsExpired { get; init; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for attestation chain verification.
|
|
/// </summary>
|
|
public sealed class AttestationChainVerifierOptions
|
|
{
|
|
/// <summary>
|
|
/// Default grace period for expired attestations in minutes.
|
|
/// </summary>
|
|
public int DefaultGracePeriodMinutes { get; set; } = 60;
|
|
|
|
/// <summary>
|
|
/// Whether to require human approval for high-severity findings.
|
|
/// </summary>
|
|
public bool RequireHumanApprovalForHighSeverity { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Maximum chain depth to verify.
|
|
/// </summary>
|
|
public int MaxChainDepth { get; set; } = 10;
|
|
|
|
/// <summary>
|
|
/// Whether to fail on missing attestations vs. reporting partial status.
|
|
/// </summary>
|
|
public bool FailOnMissingAttestations { get; set; } = false;
|
|
}
|