Releases: jakapildev/jakapil-capture
Release list
v2.0.0
Breaking changes.
AnonymizationScopeandAnonymizationOptions.Scopeare removed. The HMAC domain-separation
scope is no longer configured in your application; it is parsed out of the ingest key's scope-ref
half (jk_<scope ref>_<secret>). Any code that setoptions.Anonymization.Scopeno longer compiles
and should simply drop those lines. The reason is blunt: the scope was a set of free-text strings a
deployment could — and routinely did — leave empty, which put every such deployment into a single
shared digest domain, i.e. no separation at all. A server-issued scope ref cannot be left blank and
cannot silently disagree between two deployments of the same environment.- Every digest changes. The digest input went from
tenantId \0 projectId \0 environment \0 semanticKind \0 valuetoscopeRef \0 semanticKind \0 value,
so every fingerprint and every synthetic value this SDK produces is different from what earlier
versions produced. Correlation with any previously captured corpus is lost, and scenarios generated
from older captures must be regenerated. This is not a defect; it is the cost of the scope actually
being applied where it previously was not. - A malformed ingest key now fails startup. The key must parse as
jk_<16 uppercase-hex scope ref>_<64 uppercase-hex secret>. Keys issued before this format are no
longer valid — regenerate the key for the environment in the Jakapil UI. The key still goes to the
collector whole and unchanged on the wire. - Anonymization is required by default (
Anonymization.RequireAnonymization, new, defaults to
true). With capture enabled and the anonymization key environment variable unset, options
validation fails and the host does not start. Earlier versions shipped plaintext production
traffic in that situation, announced only by a startup log warning. Opt out deliberately with
Anonymization.RequireAnonymization = false— appropriate for a local sandbox holding no real data,
and nowhere else.
Additions. AnonymizationInfo.ScopeRef carries the scope ref every digest in the payload was
derived under. It is optional and nullable, so the wire contract stays append-only: a collector reading
payloads from older SDKs still deserializes them, and null there means "an older SDK that derived its
digests under a locally configured scope", not an error.
Note on rotation. Rotating the ingest key does not invalidate your corpus — the scope ref belongs
to the environment, not to the key material. Rotating the anonymization key does. See "Rotating the
ingest key vs. rotating the anonymization key".
v1.4.0 — Identity capture is consistent across identities, tokens and repeated claims
Identity capture is now internally consistent: the identity fields describe one identity, a minted token is attributed to the subject the response names, and a repeated claim type no longer collapses to a single value.
If you capture multi-role users or run more than one authentication handler, this is a recommended upgrade. Until now a multi-role user reached the collector with one role, and the identity block could mix fields from two different identities.
A multi-role user lost every role but one
IdentityInfo.Claims maps a claim type to a single value. A principal carrying several claims of the same type — the normal shape for a multi-role user — collapsed to one value, last writer wins:
foreach (var claim in user.Claims)
{
claims[claim.Type] = claim.Value; // second role overwrites the first
}Only one of the user's roles reached the server. That matters now that the server matches replay test users by the captured role set: a multi-role identity is mis-modelled, so matching picks the wrong account or skips a scenario that could have run.
The new IdentityInfo.MultiValuedClaims field carries the full set of values for every claim type that has more than one, in claim encounter order. Claims is untouched — it still holds one value per type — so a collector that does not know the new field keeps working exactly as before.
The field stays null unless a claim type is actually repeated. The builder does one pass over ClaimsPrincipal.Claims and only allocates the duplicate map when it sees a type for the second time, so the common single-valued case costs nothing extra on the request path and consumers do not have to check an always-empty map.
It is anonymized by the same rules as Claims, through the same IsRoleClaimType recognition and the same claim semantic kind — role types stay plaintext because the server matches on them, everything else is fingerprinted with the same key and scope. A given value therefore produces an identical envelope whichever field carries it. Without this the new field would have been a second door around anonymization, which is the exact bug class the 1.3.1 identity anonymization work closed.
Identity fields could describe two different identities
A ClaimsPrincipal commonly carries more than one ClaimsIdentity — an ASP.NET Core Identity cookie identity alongside a JWT bearer identity added by a second authentication handler on the same request. The old code read the scheme and user name from principal.Identity (always the first identity) while resolving the subject id with principal.FindFirst(...), which searches every identity. The three fields could silently come from two different identities.
CaptureBuilder now selects one authoritative identity in exactly one place — the first identity with IsAuthenticated == true, falling back to ClaimsPrincipal.Identity when none is authenticated — and reads AuthenticationScheme, UserName, SubjectId and the request/correlation subject from it.
This is a documented limitation, not a claim that the first authenticated identity is the one that authorised the request. HttpContext alone does not reveal which of several authenticated identities did, and inferring it from the raw Authorization header would couple identity capture to transport details this SDK deliberately avoids. What the selection guarantees is that those fields always describe the same identity.
Behavior change. SubjectId is now resolved only against the selected identity. If the selected identity carries no NameIdentifier/sub claim while a different identity on the same principal does, SubjectId goes from populated to null compared to earlier versions. This is intentional — a subject id belonging to a different identity than the reported scheme and user name is worse than no subject id — but it is observable on upgrade.
Claims is deliberately not narrowed to the selected identity. It stays merged across every identity on the principal, because a role claim relevant to matching may live on an identity other than the one that authorised the request; narrowing it would discard matching signal this fix is not meant to touch.
A minted token was attributed to the caller, not to its owner
AuthFlowExtractor.RegisterEmittedTokens preferred the caller's identity over the subject named in the response body:
var responseSubject = subject ?? ResponseSubject(doc.RootElement); // before
var responseSubject = ResponseSubject(doc.RootElement) ?? subject; // afterA token minted by a login/register call belongs to the subject the response body names — not to whoever happened to be signed in when the call was made, such as a service account performing the login or a previous session still active on the same connection. The response's own identity is now authoritative; the caller's identity remains the fallback for responses that carry no recognisable subject field.
Compatibility
| Wire contract | One optional field added (MultiValuedClaims); nothing removed or renamed. Older collectors ignore it. |
| Anonymization scheme | Unchanged — still hmac-sha256-v2. |
| Observable change | SubjectId may become null in the multi-identity case described above. |
Verification
338 tests passing, no warnings, on net8.0 and net10.0. New coverage: multi-valued claim capture and its anonymization (role values plaintext, non-role values fingerprinted identically to their Claims counterparts), authoritative-identity selection across multi-identity principals, and token attribution to the response subject.
Packages
Jakapil.Capture1.4.0Jakapil.Capture.Contracts1.4.0
v1.3.1 — Identity fields are anonymized; pagination parameters are not
Anonymization now covers the identity and correlation blocks, and stops corrupting pagination parameters.
If you rely on anonymization, this is a required upgrade. Until now, enabling it did not protect the caller's identity.
The gap this closes
Anonymizer rewrote only three members of a captured interaction:
return interaction with { Request = request, Response = response, Anon = ... };Identity (SubjectId, UserName, and every claim), CorrelationSignals (SubjectId, SessionCookieId, ClientConnectionId, CustomCorrelationHeader) and AuthBinding.SubjectId went through untouched.
The consequence: even with a key configured, the JWT sub, the user name and every claim — email, full name, jti included — left the process in plaintext and were persisted by the collector. Request and response bodies were being carefully fingerprinted while the identity of the person making the request travelled in the clear beside them.
What changed
| Fields | |
|---|---|
| Fingerprinted | Identity.SubjectId, Identity.UserName, every non-role claim (unrecognized claim types included, fail-closed), Correlation.SubjectId / SessionCookieId / ClientConnectionId / CustomCorrelationHeader, AuthBinding.SubjectId |
| Unchanged | IsAuthenticated, AuthenticationScheme, role claims, TraceId, SpanId, ParentSpanId, ObservedAt, SourceInteractionId, SourceFieldPath |
SessionCookieId was previously an unkeyed plain SHA-256 with no domain separation. It now goes through the same keyed fingerprint as everything else, so two tenants can no longer produce colliding digests from the same cookie value.
The scheme id moves from hmac-sha256-v1 to hmac-sha256-v2. That is how the collector knows a payload covers identity fields; it uses the value to redact raw identity arriving from older SDKs. The wire contract shape is unchanged — no field was added, removed or renamed, only the values of existing fields differ. Older collectors that treat the scheme as an opaque string are unaffected.
When no key is configured, the pass-through behavior is unchanged and the existing plaintext warning still fires.
Why role claims stay readable
A role is an authorization category — Administrators identifies no one. The set of roles in an application is also small enough that a fingerprint falls to a dictionary attack, so hiding it buys little. It costs a lot: role is the only signal that lets a replay pick a test account with equivalent permissions. Fingerprinting it would trade no real privacy for a capability.
Pagination parameters are no longer synthesized
?PageSize=10&PageIndex=0 was being captured as ?PageSize=35&PageIndex=1 — 35 being a synthetic number. Against a 12-item catalog the second page of 35 comes back empty, so the generated scenario failed its schema assertion and stayed permanently red. Anonymization was changing the meaning of the request, not just its values.
Two things caused it. Transport values are always classified as strings, so the "unrecognized number is a plain measure" rule never applied to query parameters — a pageSize in a JSON body passed through in the clear while the same field in a query string was synthesized. And the safe-literal name list did not include pagesize or pageindex.
Counter names — pagesize, pageindex, pagenumber, perpage, offset, skip, take, top — now pass through in query, route and header values only. JSON bodies are untouched by this exception.
A shape gate makes it safe: even on a name match, the exception is skipped unless the value actually looks numeric, so ?pageSize=someone@example.com can never leak. Your FieldPolicy override still wins over the exception.
sort, order and direction are deliberately not included. There is no reported failure for them, and a global entry for order would turn business references like ORD-2024-000123 into plaintext.
Known limitation: the name list is English-biased. A parameter named sayfaBoyutu will still be synthesized — declare it through FieldPolicy if you need it passed through.
Verification
326 tests passing, no warnings. Verified end to end against a real ASP.NET Core API with 50 rounds of live multi-user traffic (901 captured interactions): user names and non-role claims carry the fp: envelope while the role claim stays readable; 879 of the 879 records holding a subject id are fingerprinted; the subject digest matches across the identity, correlation and auth-binding copies in 879/879 records, so correlation still works; real user names appear in 0 of 901 records. A control run with anonymization disabled showed no regression anywhere in the downstream pipeline.
Why 1.3.0 is missing
1.3.0 contained the identity work but not the pagination fix. It was packed locally and never published; republishing the same version with different content would have served stale payloads from NuGet caches. It ships as 1.3.1 instead.
Packages
Jakapil.Capture1.3.1Jakapil.Capture.Contracts1.3.1
v1.2.1 — Masking confirmation for non-JSON response bodies
A signed-replay response now carries a masking confirmation even when its body is not JSON.
The gap this closes
Anonymizer can only mask named JSON leaves — there is no safe, general way to anonymize an arbitrary text or binary blob without a schema. Until now, when a replay response came back as text/plain (or any non-JSON content type), masking simply did not run and no confirmation header was sent at all.
The cloud side treats a missing confirmation as "do not persist the body" (fail-closed), which is correct for privacy — but it meant a whole class of steps could never be verified. A 409 Conflict with a plain-text message is one of the most common error shapes in ASP.NET (Response.WriteAsync("...") defaults to text/plain when no content type is set), so negative test scenarios expecting a 4xx were silently unverifiable.
What changed
The confirmation header gained an optional body= field:
X-Jakapil-Masked: v1;scheme=hmac-sha256-v1;keyVersion=1;body=unmasked-nonjson;live=...
- Values:
masked|unmasked-nonjson. - The field is optional. When absent, a receiver assumes
masked— this is what keeps 1.2.0 and earlier working unchanged. Add-only; nothing breaks. - A non-JSON body is now forwarded unchanged and that fact is declared honestly, rather than being passed through in silence.
Set-Cookie/Location RunCredential handling still runs on this path — those inspect headers, not the body, so a non-JSON body does not lose that protection.
Why pass through rather than mask
Symmetry with the capture path is the governing principle. Capture already forwards non-JSON bodies unchanged, on purpose. Masking the replay body into an opaque value while capture stores it raw would put the two sides in different spaces and make every assertion on that body fail — reintroducing exactly the false-regression problem this mechanism exists to prevent.
Still fail-closed (deliberately unchanged)
Two cases continue to withhold the header entirely:
- Malformed JSON — the content type claims JSON but the body does not parse. Its content could not be classified, so masking genuinely did not happen and cannot be reasoned about.
- Truncated body — exceeded
Replay.MaxMaskedResponseBytes. Such a body is both unmasked and incomplete, so any assertion built on it is unreliable.
An empty body is unaffected: it is trivially masked regardless of content type and keeps the plain confirmation header, exactly as before.
Verification
305 tests passing, no warnings. End-to-end verified against a real ASP.NET target with live traffic and real signed runs: a 409 text/plain step that produced no confirmation under 1.2.0 now reports body=unmasked-nonjson and its status assertion passes. Privacy checks on the persisted run snapshot came back clean — zero raw JWTs, zero plaintext passwords.
Packages
Jakapil.Capture1.2.1Jakapil.Capture.Contracts1.2.1
v1.2.0 — Signed replay verification and in-process response masking
Runs executed against a live target no longer send real data to the cloud.
Signed replay verification
The middleware recognises a signed replay request from Jakapil, verifies the
signature, and masks the response in-process before it leaves the machine. The
cloud sees synthetic values on both the capture side and the run side, so
assertions compare like with like — this removes a whole class of false
regressions where an anonymized field was compared against a real one.
- ECDSA P-256, not Ed25519. Ed25519 is not in the .NET 10 BCL, and adding a
third-party crypto package to your process is a supply-chain and
version-conflict risk. The algorithm identifier travels in the header, so a
future migration will not break existing deployments. - Asymmetric. You configure a public key only. With a shared secret, anyone
who can read your configuration could forge a replay request. - Replay protection. A nonce cache rejects repeated signatures inside the
accepted time window. The canonical string binds method, path, body hash, key
id and timestamp, so no field can be swapped after signing. - Idempotent masking. Applying masking twice yields the same result, which
matters when a value crosses more than one hop. - Live pass-through is declared, not assumed. The response states which
leaves were returned live. Only the run-credential family (token, session,
cookie, csrf, cursor) is eligible. Passwords are never passed through. The
declaration is advisory — the Jakapil side enforces its own guard on top.
Anonymization fixes
- Context-aware classification. A field named
nameis no longer assumed to
be a person's name; the parent path and sibling fields narrow what a leaf can
be. Product catalogs stop getting synthetic person names. - Type-preserving synthetic values. Numeric and boolean leaves kept their
JSON type instead of becoming strings. Targets were returning 400 because a
quantity field carried a synthetic name. Type now also survives fingerprint
round-trips.
Verification
295 tests passing, no warnings. Signature test vectors under tests/vectors are
shared with the Jakapil side, so both implementations are checked against the
same canonical inputs. End-to-end verified against a real ASP.NET target with
live traffic.
Packages
Jakapil.Capture1.2.0Jakapil.Capture.Contracts1.2.0
1.1.0
feat(anonymization): ADR-0002 capture anonymization — fingerprint + s…
1.0.1
chore: bump to 1.0.1 — republish with English strings
1.0.0
feat: extract Jakapil.Capture as a standalone repo + NuGet packages