Skip to content

Commit 538e49b

Browse files
committed
fix(sync): preserve listing metadata on refetch; honest DCV summary; audit logging
Addresses a refetch bug the new logging surfaced, plus compliance-audit findings: - Refetch metadata loss: GetCertificateAsync returns the body but NOT Subject / ProfileId / OrderDate, and the refetch replaced `current` wholesale — so issued certs synced with a null Subject (visible in logs: "Subject=(null)") and, worse, a null ProductID (ProfileId feeds AnyCAPluginCertificate.ProductID). Carry the listing's Subject/ProfileId/OrderDate across the refetch. - DCV summary honesty (issue 0003 / SOC2 CC7.3): only report DCV attempt counts when DCV is actually operational (DcvEnabled AND a DNS-provider factory injected). On a host that doesn't supply one (e.g. IAnyCAPlugin 3.2.0) the summary now says DCV is "not active" instead of a misleading Attempted=N — the gate is gated on dcvOperational. - Aged-out audit (audit H-1): SkipByAge orders are logged at Information with CARequestID/OrderDate so abandoned pending orders are auditable in production. - skippedWithBody counter in the completion summary so it's visible whether any body-carrying cert was skipped (expected 0). 172 unit tests pass; builds clean against IAnyCAPlugin 3.2.0 (0 warnings).
1 parent d57a526 commit 538e49b

1 file changed

Lines changed: 48 additions & 11 deletions

File tree

CERTInext/CERTInextCAPlugin.cs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -783,12 +783,19 @@ public async Task Synchronize(
783783

784784
int synced = 0;
785785
int skipped = 0;
786+
int skippedWithBody = 0; // skipped records that nonetheless carried a cert body (should be 0)
786787
int errors = 0;
787788

788-
// Bounds on DCV-during-sync so a large pending backlog can't make a pass slow (issue 0002).
789+
#if SUPPORTS_DCV
790+
// DCV-during-sync only actually runs when DCV is enabled AND a DNS provider factory was
791+
// injected by the host. On a gateway that doesn't supply one (e.g. IAnyCAPlugin 3.2.0
792+
// hosts), DCV cannot run even on a DCV-capable build — so don't run the gate or report
793+
// attempt counts that would imply it did (issue 0003). Bounds apply only when operational.
794+
bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null;
789795
int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; // 0 = no age filter
790796
int perPassCap = _config.DcvSyncMaxPerPass; // 0 = no cap
791797
int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0;
798+
#endif
792799

793800
// Emit-side accounting (issue 0003): what the plugin hands to the gateway buffer.
794801
int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0;
@@ -813,6 +820,7 @@ public async Task Synchronize(
813820
_logger.LogTrace(
814821
"Skipping expired certificate '{Id}' (expires {ExpiresAt:u}).",
815822
current.Id, current.ExpiresAt.Value);
823+
if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++;
816824
skipped++;
817825
continue;
818826
}
@@ -838,7 +846,8 @@ public async Task Synchronize(
838846
// and revisited on a later pass (the per-minute incremental scan keeps recent
839847
// orders moving). Unknown order age → treat as eligible so we never starve a
840848
// legitimately-new order.
841-
if (status == (int)EndEntityStatus.EXTERNALVALIDATION)
849+
#if SUPPORTS_DCV
850+
if (dcvOperational && status == (int)EndEntityStatus.EXTERNALVALIDATION)
842851
{
843852
var decision = EvaluateDcvSyncEligibility(
844853
current.OrderDate, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap);
@@ -851,6 +860,14 @@ public async Task Synchronize(
851860

852861
if (decision == DcvSyncDecision.SkipByAge)
853862
{
863+
// Issue 0003 / SOC1 completeness: an order past the age window is no
864+
// longer advanced by sync (it only ages further), so record its
865+
// identity at Information — not just the aggregate count — so an
866+
// auditor can see which orders were left parked, and when.
867+
_logger.LogInformation(
868+
"Sync: pending DV order aged out of the DCV-during-sync window and will " +
869+
"not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.",
870+
current.Id, current.OrderDate?.ToString("o") ?? "(none)", ageWindowHours);
854871
dcvSkippedAge++;
855872
}
856873
else if (decision == DcvSyncDecision.SkipByCap)
@@ -877,13 +894,15 @@ public async Task Synchronize(
877894
}
878895
}
879896
}
897+
#endif
880898

881899
// Skip failed/rejected/cancelled certificates — they have no cert body
882900
if (status == (int)EndEntityStatus.FAILED)
883901
{
884902
_logger.LogTrace(
885903
"Skipping certificate '{Id}' with terminal failure status '{Status}'.",
886904
current.Id, current.Status);
905+
if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++;
887906
skipped++;
888907
continue;
889908
}
@@ -903,13 +922,22 @@ public async Task Synchronize(
903922
_logger.LogDebug(
904923
"Sync: issued/revoked order Id={Id} has no body in the listing — refetching full certificate.",
905924
current.Id);
925+
// The order-report listing carries metadata (Subject/DomainName,
926+
// ProfileId/ProductCode, OrderDate) that GetCertificateAsync (TrackOrder +
927+
// DownloadCertificate) does NOT return. The refetch replaces `current`
928+
// wholesale, so carry that listing metadata across or the emitted record
929+
// loses its Subject and ProductID (ProductID feeds the Command template).
930+
var listed = current;
906931
try
907932
{
908933
current = await _client.GetCertificateAsync(current.Id, cancelToken);
934+
current.Subject = string.IsNullOrWhiteSpace(current.Subject) ? listed.Subject : current.Subject;
935+
current.ProfileId = string.IsNullOrWhiteSpace(current.ProfileId) ? listed.ProfileId : current.ProfileId;
936+
current.OrderDate ??= listed.OrderDate;
909937
status = StatusMapper.ToRequestDisposition(current.Status);
910938
_logger.LogDebug(
911-
"Sync: refetched order Id={Id} — status={Status}, certBytes={Bytes}.",
912-
current.Id, status, current.Certificate?.Length ?? 0);
939+
"Sync: refetched order Id={Id} — status={Status}, certBytes={Bytes}, subject={Subject}.",
940+
current.Id, status, current.Certificate?.Length ?? 0, current.Subject);
913941
}
914942
catch (Exception fetchEx)
915943
{
@@ -980,15 +1008,24 @@ public async Task Synchronize(
9801008
}
9811009
}
9821010

1011+
// Build the DCV-during-sync clause for the ACTUAL runtime state so the summary
1012+
// never implies DCV ran when it couldn't (issue 0003 / SOC2 CC7.3 accuracy).
1013+
string dcvClause;
1014+
#if SUPPORTS_DCV
1015+
if (dcvOperational)
1016+
dcvClause = $"DCV-during-sync: Attempted={dcvAttempted}, SkippedByAge={dcvSkippedAge} (>{ageWindowHours}h), SkippedByCap={dcvSkippedCap} (cap={perPassCap}).";
1017+
else
1018+
dcvClause = $"DCV-during-sync: not active (DcvEnabled={_config.DcvEnabled}, DnsProviderInjected={_domainValidatorFactory != null}) — pending orders left as EXTERNALVALIDATION.";
1019+
#else
1020+
dcvClause = "DCV-during-sync: not supported on this build (IAnyCAPlugin 3.2.0).";
1021+
#endif
9831022
_logger.LogInformation(
984-
"CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}. " +
985-
"Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, GeneratedNoBody={GenNoBody}, " +
986-
"Revoked={Revoked}, Pending={Pending}. " +
987-
"DCV-during-sync: Attempted={DcvAttempted}, SkippedByAge={DcvSkippedAge} (>{AgeHours}h), " +
988-
"SkippedByCap={DcvSkippedCap} (cap={Cap}).",
989-
synced, skipped, errors,
1023+
"CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " +
1024+
"Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
1025+
"GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}. {DcvClause}",
1026+
synced, skipped, skippedWithBody, errors,
9901027
emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
991-
dcvAttempted, dcvSkippedAge, ageWindowHours, dcvSkippedCap, perPassCap);
1028+
dcvClause);
9921029
}
9931030
catch (OperationCanceledException)
9941031
{

0 commit comments

Comments
 (0)