Skip to content

Commit 1d8c0dc

Browse files
committed
fix: compliance triage on issue #7 follow-up + issue #8
Addresses findings from the compliance-auditor pass on commits 054b2c3 and aab1847. == Material == M1. LogApiFailure now scrubs known credential fields from response bodies before logging. The CERTInext request meta block includes a SHA-256 `authKey` digest that is itself a replayable credential under SOX (anyone with a valid (ts, txn, authKey) triple can replay until the timestamp window expires); the helper had no guard against the digest appearing in an echoed response body. New static `RedactCredentials` applies a regex pass over JSON (`"authKey": "..."`, `"client_secret"`, `"apiKey"`, `"accessKey"`, `"password"`), form-urlencoded (`client_secret=...`, `authKey=...`), and `Authorization:` header lines, replacing values with `***REDACTED***`. Also added an explicit XML-doc warning forbidding use of LogApiFailure on the OAuth token-exchange path — that path's request body contains the plaintext `client_secret` and has its own dedicated log-suppression comment at the existing throw site. M2. PlaceOrder now emits a cumulative LogInformation on the success branch when at least one rate-limit retry fired: "PlaceOrder succeeded after rate-limit retries. OrderNumber=..., RateLimitRetryCount=N, TotalBackoffSeconds=S.S". An operator scraping gateway logs for rate-limit pressure (SOC2 CC7.2 anomaly detection) now gets one line per call rather than having to thread per-attempt warnings by OrderNumber. M3. IsRateLimitSurface gained an explicit XML-doc contract section: callers MUST only invoke inside the !result.Meta.IsSuccess branch, and the known cost of burning up to 5 enrollment attempts on a genuinely- inactive account is documented (the rate-limit and the inactive-account conditions return the same string with no distinguishing errorCode). == Advisory == A1. Field-reflection test in CERTInextCAPluginPublicSurfaceTests adds BindingFlags.DeclaredOnly for symmetry with the nested-type and method-signature checks below it. No behavioural change — GetFields already returns declared-only for instance fields — but explicit-is- better-than-implicit in a reflection guard test. A3. ComputeRateLimitBackoffSeconds XML-doc now documents the thundering-herd assumption: the SecureRandom-backed jitter is process-wide; concurrent callers in the same process get independent samples; cross-pod fleets each have their own SecureRandom instance so jitter is also independent across pods. A4. LogApiFailure gained an optional `LogLevel level = Warning` parameter. ValidateCredentials passes `LogLevel.Error` so SOX-required SIEM rules on authentication failures fire (a meta-failure on ValidateCredentials is by definition an auth event). Other callers keep the Warning default — meta-failure-on-HTTP-200 is the CA saying "no" to a business request. == Tests == New RedactCredentialsTests pins the scrubber across JSON, form-urlencoded, Authorization-header, mixed-case, and null/empty inputs. Tests now run 182/182 (171 prior + 8 redact + 3 nullable / casing checks). Live ping/sync against the sandbox still authenticates. == Deferred (none) == No findings deferred from this audit pass. The remaining open item from the prior compliance audit is the A4 finding on IsDcvNotYetReady distinguishability, unchanged.
1 parent aab1847 commit 1d8c0dc

3 files changed

Lines changed: 232 additions & 14 deletions

File tree

CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ public void NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes()
7373
// a regression of either: field types must use only types the v3.2 host
7474
// ships, with `object` as the typical neutral-typed storage and an `as`
7575
// cast inside method bodies (JIT-lazy) for actual use.
76+
// DeclaredOnly added for symmetry with the nested-type / method tests below
77+
// and to make the "we only check this type, not its base classes" intent
78+
// explicit in the reflection-query shape.
7679
var fields = typeof(CERTInextCAPlugin)
77-
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
80+
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
7881

7982
foreach (var field in fields)
8083
{
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
5+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
6+
// and limitations under the License.
7+
8+
using FluentAssertions;
9+
using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
10+
using Xunit;
11+
12+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
13+
{
14+
/// <summary>
15+
/// Pins the credential-scrubbing pass that <see cref="LogApiFailure"/> runs on
16+
/// every response body before truncation. The CERTInext request meta block
17+
/// includes an <c>authKey</c> SHA-256 digest that is itself a replayable
18+
/// credential under SOX (anyone with one valid <c>(ts, txn, authKey)</c> triple
19+
/// can replay until the timestamp window expires). These tests pin that the
20+
/// scrubber catches both the documented-as-sent fields (<c>authKey</c>) and
21+
/// adjacent credential field names that *could* end up on the wire if a future
22+
/// code path wires them in (<c>client_secret</c>, <c>accessKey</c>, <c>password</c>).
23+
/// See the audit report for commit aab1847.
24+
/// </summary>
25+
public class RedactCredentialsTests
26+
{
27+
[Theory]
28+
[InlineData(
29+
"{\"meta\":{\"authKey\":\"deadbeefdeadbeefdeadbeef\",\"ts\":\"2026\"}}",
30+
"{\"meta\":{\"authKey\":\"***REDACTED***\",\"ts\":\"2026\"}}")]
31+
[InlineData(
32+
"{\"client_secret\":\"super-secret-12345\"}",
33+
"{\"client_secret\":\"***REDACTED***\"}")]
34+
[InlineData(
35+
"{\"apiKey\":\"raw-access-key-value\",\"other\":\"keep\"}",
36+
"{\"apiKey\":\"***REDACTED***\",\"other\":\"keep\"}")]
37+
[InlineData(
38+
"{\"accessKey\":\"xxx\",\"password\":\"yyy\"}",
39+
"{\"accessKey\":\"***REDACTED***\",\"password\":\"***REDACTED***\"}")]
40+
public void RedactCredentials_ScrubsJsonCredentialFields(string input, string expected)
41+
{
42+
CERTInextClient.RedactCredentials(input).Should().Be(expected);
43+
}
44+
45+
[Theory]
46+
[InlineData(
47+
"grant_type=client_credentials&client_secret=super-secret-12345&client_id=public-id",
48+
"grant_type=client_credentials&client_secret=***REDACTED***&client_id=public-id")]
49+
[InlineData(
50+
"authKey=abc123def456",
51+
"authKey=***REDACTED***")]
52+
public void RedactCredentials_ScrubsFormUrlEncodedCredentialFields(string input, string expected)
53+
{
54+
CERTInextClient.RedactCredentials(input).Should().Be(expected);
55+
}
56+
57+
[Fact]
58+
public void RedactCredentials_ScrubsAuthorizationHeaderLines()
59+
{
60+
string input =
61+
"POST /token HTTP/1.1\r\n" +
62+
"Host: example.com\r\n" +
63+
"Authorization: Bearer ya29.abcdef-secret-token\r\n" +
64+
"Content-Type: application/json\r\n";
65+
string output = CERTInextClient.RedactCredentials(input);
66+
output.Should().Contain("Authorization: ***REDACTED***");
67+
output.Should().NotContain("ya29.abcdef-secret-token");
68+
output.Should().Contain("Host: example.com");
69+
output.Should().Contain("Content-Type: application/json");
70+
}
71+
72+
[Fact]
73+
public void RedactCredentials_PreservesNonCredentialFields()
74+
{
75+
string input = "{\"meta\":{\"ts\":\"2026-05-22\",\"txn\":\"12345\",\"errorMessage\":\"Inactive Account User.\"}}";
76+
string output = CERTInextClient.RedactCredentials(input);
77+
output.Should().Be(input, "non-credential fields must pass through unchanged");
78+
}
79+
80+
[Theory]
81+
[InlineData(null)]
82+
[InlineData("")]
83+
public void RedactCredentials_HandlesNullAndEmpty(string input)
84+
{
85+
// Should not throw and should return the input unchanged (or empty for null).
86+
// The current implementation returns the input as-is for these edge cases.
87+
CERTInextClient.RedactCredentials(input).Should().Be(input);
88+
}
89+
90+
[Fact]
91+
public void RedactCredentials_CaseInsensitiveFieldNameMatch()
92+
{
93+
// CERTInext historically uses mixed casing (`AuthKey`, `apiKey`, etc.)
94+
// depending on the endpoint. Make sure none slip past the scrubber.
95+
string input = "{\"AuthKey\":\"abc\",\"APIKEY\":\"def\",\"ClientSecret\":\"xyz\"}";
96+
97+
string output = CERTInextClient.RedactCredentials(input);
98+
99+
// ClientSecret isn't currently in the redaction list (only client_secret is),
100+
// and that's intentional — the JSON convention CERTInext uses is the
101+
// snake_case form on the OAuth token endpoint. If we ever observe
102+
// CamelCase variants on the wire, extend the regex. Documented here so
103+
// a future regression review catches the gap.
104+
output.Should().Contain("\"AuthKey\":\"***REDACTED***\"");
105+
output.Should().Contain("\"APIKEY\":\"***REDACTED***\"");
106+
}
107+
}
108+
}

CERTInext/Client/CERTInextClient.cs

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ public async Task PingAsync(CancellationToken ct = default)
160160
var result = DeserializeOrThrow<ValidateCredentialsResponse>(resp, "validate credentials");
161161
if (result.Meta != null && !result.Meta.IsSuccess)
162162
{
163-
LogApiFailure("ValidateCredentials", resp, result.Meta.ErrorCode, result.Meta.ErrorMessage);
163+
// Authentication-failure-shaped event: log at Error so SOX-required
164+
// SIEM rules on authentication failures fire. Every other meta-failure
165+
// call site logs at the LogApiFailure default (Warning).
166+
LogApiFailure("ValidateCredentials", resp,
167+
result.Meta.ErrorCode, result.Meta.ErrorMessage,
168+
level: LogLevel.Error);
164169
throw new Exception(
165170
$"CERTInext credential validation failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " +
166171
"See gateway logs for details.");
@@ -187,6 +192,11 @@ public async Task<GenerateOrderResponse> PlaceOrderAsync(
187192

188193
GenerateOrderResponse result = null;
189194
RestResponse resp = null;
195+
// Cumulative backoff time across all rate-limit retries this call. Emitted
196+
// on the success branch so an operator scraping gateway logs for rate-limit
197+
// pressure (SOC2 CC7.2 anomaly-detection) can correlate by single log line
198+
// rather than threading per-attempt warnings by OrderNumber.
199+
double totalRateLimitBackoffSeconds = 0.0;
190200

191201
// Issue #8 rate-limit retry: the sandbox returns "Inactive Account User."
192202
// as a generic error string for several conditions, including burst-rate-limit
@@ -233,6 +243,7 @@ public async Task<GenerateOrderResponse> PlaceOrderAsync(
233243
if (IsRateLimitSurface(result.Meta.ErrorMessage) && attempt < RateLimitMaxAttempts)
234244
{
235245
double waitSeconds = ComputeRateLimitBackoffSeconds(attempt);
246+
totalRateLimitBackoffSeconds += waitSeconds;
236247
Logger.LogWarning(
237248
"PlaceOrder hit rate-limit-shaped error \"{ErrorMessage}\" (attempt {Attempt}/{Max}). " +
238249
"Backing off {WaitSeconds:F1}s before retrying. See Troubleshooting in README for context.",
@@ -253,6 +264,16 @@ public async Task<GenerateOrderResponse> PlaceOrderAsync(
253264
"See gateway logs for details.");
254265
}
255266

267+
// Success — if we retried, emit a single summary line so the rate-limit
268+
// pressure is correlatable per-call without joining the per-attempt
269+
// warnings by OrderNumber. (SOC2 CC7.2 anomaly-detection enablement.)
270+
if (attempt > 1)
271+
{
272+
Logger.LogInformation(
273+
"PlaceOrder succeeded after rate-limit retries. OrderNumber={OrderNumber}, " +
274+
"RateLimitRetryCount={RetryCount}, TotalBackoffSeconds={BackoffSeconds:F1}",
275+
result.OrderDetails?.OrderNumber, attempt - 1, totalRateLimitBackoffSeconds);
276+
}
256277
break; // success
257278
}
258279

@@ -1471,6 +1492,24 @@ private static T DeserializeOrThrow<T>(RestResponse resp, string operation) wher
14711492
/// True when <paramref name="errorMessage"/> matches the documented rate-limit
14721493
/// surface CERTInext uses on its sandbox. Substring + case-insensitive match;
14731494
/// the trailing punctuation/whitespace varies across observed payloads.
1495+
///
1496+
/// <para>
1497+
/// <b>Contract:</b> callers MUST only invoke this inside the
1498+
/// <c>!result.Meta.IsSuccess</c> branch of an API response. CERTInext's
1499+
/// successful responses are not currently observed to include this phrase,
1500+
/// but the predicate is intentionally permissive to handle CA-side wording
1501+
/// drift, and we want the safety net of the surrounding failure context.
1502+
/// </para>
1503+
///
1504+
/// <para>
1505+
/// <b>Known cost:</b> a genuinely-inactive account (admin disabled, billing
1506+
/// hold) returns the same error string as a rate-limit hit. Today there is
1507+
/// no distinguishing <c>errorCode</c> field in the observed payloads, so
1508+
/// callers gated by this predicate will exhaust their full retry budget
1509+
/// (5 attempts × ~31 s total wait) before propagating the original failure
1510+
/// to the gateway. Quota cost: up to 5 enrollment attempts per affected
1511+
/// call. See GitHub issue #8 for the discussion.
1512+
/// </para>
14741513
/// </summary>
14751514
internal static bool IsRateLimitSurface(string errorMessage)
14761515
{
@@ -1481,7 +1520,19 @@ internal static bool IsRateLimitSurface(string errorMessage)
14811520
/// <summary>
14821521
/// Exponential backoff with ±25% jitter for the rate-limit retry inside
14831522
/// <see cref="PlaceOrderAsync"/>. Attempts 1..5 produce roughly
1484-
/// 1s / 2s / 4s / 8s / 16s of nominal delay (jitter spreads concurrent callers).
1523+
/// 1s / 2s / 4s / 8s / 16s of nominal delay.
1524+
///
1525+
/// <para>
1526+
/// <b>Thundering-herd assumption:</b> jitter is sampled from a process-wide
1527+
/// <see cref="Org.BouncyCastle.Security.SecureRandom"/> (<c>_txnRandom</c>),
1528+
/// so concurrent callers in the same process get independent samples.
1529+
/// Multiple gateway pods hitting the same CERTInext tenant each have their
1530+
/// own seeded instance, so jitter is also independent across pods. The
1531+
/// ±25% spread on the 16s nominal at attempt 5 produces a 4s window — wide
1532+
/// enough to de-correlate from the documented "~16 orders / 10 s" sandbox
1533+
/// limit if a multi-pod fleet hits the limit simultaneously.
1534+
/// </para>
1535+
///
14851536
/// Exposed <c>internal</c> so unit tests can verify the schedule.
14861537
/// </summary>
14871538
internal static double ComputeRateLimitBackoffSeconds(int attempt)
@@ -1514,35 +1565,91 @@ private static string Truncate(string s, int max)
15141565
}
15151566

15161567
/// <summary>
1517-
/// Writes a structured <c>LogWarning</c> capturing every diagnostic field
1518-
/// available for a non-success CERTInext API response — HTTP status, the
1519-
/// CERTInext-side error code and message, and the (truncated) raw response
1520-
/// body. Call this immediately before throwing so the exception's
1521-
/// "See gateway logs for details" instruction actually points somewhere useful.
1568+
/// Scrubs known credential-bearing keys out of a JSON-ish body before it goes
1569+
/// into a log line. CERTInext error envelopes are not currently observed to
1570+
/// echo request fields, but the response shape isn't contractually fixed and
1571+
/// the <c>authKey</c> digest in the request meta block IS a replayable
1572+
/// privileged credential under SOX (anyone with one valid
1573+
/// <c>(ts, txn, authKey)</c> triple can replay until the timestamp window expires).
1574+
/// Defense-in-depth: redact before logging, not after a leak.
1575+
///
1576+
/// Conservative substring/regex pass — handles JSON, form-urlencoded, and
1577+
/// header-line shapes. Exposed <c>internal</c> for unit-testing.
1578+
/// </summary>
1579+
internal static string RedactCredentials(string body)
1580+
{
1581+
if (string.IsNullOrEmpty(body)) return body;
1582+
1583+
// JSON: "authKey": "..." → "authKey":"***REDACTED***"
1584+
// JSON: "client_secret":"..." → same
1585+
// JSON: "ApiKey":"..." → same (defensive — not currently sent on the wire,
1586+
// but the field name is a common one and the cost of redacting it is zero).
1587+
body = System.Text.RegularExpressions.Regex.Replace(
1588+
body,
1589+
@"(?i)""(authKey|client_secret|apiKey|accessKey|password)""\s*:\s*""[^""]*""",
1590+
@"""$1"":""***REDACTED***""");
1591+
1592+
// Form-urlencoded: client_secret=... or authKey=... (before any & or end)
1593+
body = System.Text.RegularExpressions.Regex.Replace(
1594+
body,
1595+
@"(?i)\b(authKey|client_secret|apiKey|accessKey|password)=([^&\s""]+)",
1596+
"$1=***REDACTED***");
1597+
1598+
// Authorization header lines if a header dump ever ends up in body shape.
1599+
// Match through end-of-line so multi-token values (e.g. "Bearer <token>")
1600+
// are fully scrubbed, not just the scheme word.
1601+
body = System.Text.RegularExpressions.Regex.Replace(
1602+
body,
1603+
@"(?im)^Authorization:[^\r\n]*",
1604+
"Authorization: ***REDACTED***");
1605+
1606+
return body;
1607+
}
1608+
1609+
/// <summary>
1610+
/// Writes a structured log capturing every diagnostic field available for a
1611+
/// non-success CERTInext API response — HTTP status, the CERTInext-side error
1612+
/// code and message, and the (truncated, credential-scrubbed) raw response body.
1613+
/// Call this immediately before throwing so the exception's "See gateway logs
1614+
/// for details" instruction actually points somewhere useful.
15221615
///
15231616
/// Background: issue #8 surfaced that the sandbox returns the generic string
15241617
/// <c>"Inactive Account User."</c> for several conditions including burst
15251618
/// rate-limit rejection. Without the raw body in the log, an operator has no
15261619
/// way to disambiguate "the account is genuinely inactive" from "you submitted
15271620
/// 16 orders in 10 seconds and the CA's burst quota kicked in."
1621+
///
1622+
/// <para>
1623+
/// <b>Do NOT call this helper from the OAuth token-exchange path</b> — that
1624+
/// request body contains the plaintext <c>client_secret</c>, and while
1625+
/// <see cref="RedactCredentials"/> scrubs known credential keys defensively,
1626+
/// the token-exchange path has its own explicit log-suppression comment at
1627+
/// the existing throw site and we want to keep that path's blast radius tight.
1628+
/// </para>
1629+
///
1630+
/// Default <paramref name="level"/> is <see cref="LogLevel.Warning"/> — meta-failure-on-HTTP-200
1631+
/// is the CA saying "no" to a request, a business outcome rather than a plugin
1632+
/// fault. Callers handling authentication failures should pass
1633+
/// <see cref="LogLevel.Error"/> so SOX-loggable authentication events match
1634+
/// the SIEM-alert level convention.
15281635
/// </summary>
15291636
private static void LogApiFailure(
15301637
string operationContext,
15311638
RestResponse resp,
15321639
string errorCode = null,
1533-
string errorMessage = null)
1640+
string errorMessage = null,
1641+
LogLevel level = LogLevel.Warning)
15341642
{
1535-
// Keep log level at Warning — meta-failure-on-HTTP-200 is the CA saying
1536-
// "no" to a request, which is a business outcome, not a plugin error.
1537-
// True transport/auth failures already log at Error elsewhere.
1538-
Logger.LogWarning(
1643+
string sanitizedBody = RedactCredentials(resp?.Content) ?? "(empty)";
1644+
Logger.Log(
1645+
level,
15391646
"CERTInext API non-success. Operation={Operation}, HttpStatus={HttpStatus}, " +
15401647
"ErrorCode={ErrorCode}, ErrorMessage={ErrorMessage}, ResponseBody={ResponseBody}",
15411648
operationContext,
15421649
(int?)resp?.StatusCode ?? 0,
15431650
errorCode ?? "(none)",
15441651
errorMessage ?? "(none)",
1545-
Truncate(resp?.Content, LoggedResponseBodyCapBytes) ?? "(empty)");
1652+
Truncate(sanitizedBody, LoggedResponseBodyCapBytes));
15461653
}
15471654

15481655
private static string ExtractErrorMessage(string content, string operation)

0 commit comments

Comments
 (0)