Sekret
Kotlin compiler plugin to hide secret data
Install / Use
/learn @aafanasev/SekretREADME
Sekret
A Kotlin compiler plugin that prevents sensitive data class properties from appearing in generated toString() output — enforced at compile time, with zero runtime cost.
Poll
Please join the discussion: https://github.com/aafanasev/sekret/discussions/50#discussion-8585307
The Problem
Logs are an invisible attack surface
Every production system logs. Logs flow through application servers, distributed tracing systems, log aggregators, SIEM platforms, third-party monitoring services, and long-term cold storage. Each hop is a potential breach point.
The source of the exposure is rarely malicious code. It is almost always accidental: a developer logs a request object, a framework serializes a data class for debugging, or an exception handler prints the state of an object that happened to contain a password, token, or card number.
Kotlin's data class makes this worse by design. toString() is auto-generated and includes every property by default. Developers rarely think about what a data class emits when logged — because they never wrote the toString() method.
Real incidents at scale
In 2019, Facebook disclosed that hundreds of millions of user passwords were stored in plaintext in internal log files — accessible to thousands of employees. The same year, Google acknowledged a similar exposure affecting G Suite administrators. These were not external breaches; they were the result of sensitive values flowing unguarded through logging pipelines.
In 2020, Twitter disclosed that account data including phone numbers and email addresses was inadvertently written to internal logs and made accessible to employees and contractors.
These incidents are not anomalies. They represent a systemic pattern: sensitive data enters a system, passes through code that auto-serializes it, and ends up in logs that were never designed to hold it.
Why code review cannot solve this
A security-conscious reviewer can audit toString() overrides. But generated toString() methods are invisible in source — they do not exist as code to review. In a codebase with hundreds of data classes, evolving models, and multiple contributors, there is no reliable way to audit this manually. One added field in a data class, one forgotten annotation, and credentials flow into your log aggregator.
Regulatory and legal consequences
Regulators treat logging of plaintext credentials and PII as a compliance violation, not merely a best-practice failure:
- GDPR (EU): Logging personally identifiable information without appropriate controls can result in fines up to 4% of global annual revenue.
- PCI-DSS: Storing or logging full card numbers, CVVs, or PINs — even accidentally — is a violation that can result in loss of payment processing rights.
- HIPAA (US healthcare): Logging protected health information in plaintext is a reportable breach.
- National security contexts: Systems handling government credentials, citizen identity data, or critical infrastructure access tokens face even stricter obligations. A single misconfigured log pipeline in such a system can expose state-level secrets to anyone with access to the log store.
The cost of a logging-related breach is not limited to fines. It includes breach notification obligations, forensic investigation, remediation, reputational damage, and in some jurisdictions, personal liability for engineers and executives.
The Solution
Sekret is a Kotlin compiler plugin. It operates at the IR (Intermediate Representation) level during compilation — before bytecode is generated — and rewrites the toString() method of annotated data classes to replace sensitive field values with a mask string.
What this means in practice:
- The sensitive value cannot appear in
toString()output. There is no runtime check that could be bypassed, no wrapper that could be forgotten, no interface to implement incorrectly. - The mask is applied in the generated bytecode. There is no runtime reflection, no proxy, and no overhead in the hot path.
- The
@Secretannotation hasSOURCEretention and is not present at runtime. The compiled artifact carries no trace of it. - Enforcement is structural: adding a new
@Secret-annotated field to a data class automatically protects it. Removing the annotation is a deliberate, reviewable change.
Usage
Basic field masking
data class Credentials(
val login: String,
@Secret val password: String
)
println(Credentials("user@example.com", "hunter2"))
// Output: Credentials(login=user@example.com, password=■■■)
Class-level masking
When an entire data class represents sensitive state, annotate the class itself:
@Secret
data class AuthToken(
val value: String,
val expiresAt: Long
)
println(AuthToken("eyJhbGci...", 1700000000))
// Output: AuthToken(■■■)
Partial masking with regex
For cases where a partial value must be visible (e.g., last four digits of a card number, masked phone number), use a custom annotation with search and replacement fields:
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class Masked(val search: String, val replacement: String)
data class PaymentCard(
@Masked("([0-9]{4})([0-9]{8})([0-9]{4})", "****-****-****-$3")
val number: String
)
println(PaymentCard("1234567890123456"))
// Output: PaymentCard(number=****-****-****-3456)
If the value does not match the pattern, the default mask is applied. Replacement patterns that would expose the original value (e.g., $0) are blocked.
Installation
Gradle
Apply the plugin:
plugins {
id 'net.afanasev.sekret' version '<version>'
}
Add the annotation dependency:
dependencies {
implementation 'net.afanasev:sekret-annotation:<version>'
}
Configure (all settings are optional):
sekret {
// Mask string shown in place of secret values. Default: "■■■"
mask = "***"
// Set to false to disable the plugin entirely (e.g., in debug builds). Default: true
enabled = true
// Use your own annotation instead of (or in addition to) @Secret. Default: "net.afanasev.sekret.Secret"
annotations = ["com.example.Confidential"]
}
Custom annotation requirements
If you define your own secret annotation, it must use SOURCE retention so it does not appear at runtime:
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class Confidential
Kotlin CLI
kotlinc \
-Xplugin=kotlin-plugin.jar \
-P plugin:sekret:annotations=com.example.Confidential \
...
Why compile-time enforcement matters
Runtime approaches to sensitive data masking — custom toString() overrides, wrapper types, serialization filters — all share the same weakness: they rely on developers remembering to apply them, and they can be bypassed or omitted.
A compiler plugin enforces the contract before the code runs. The sensitive value is removed from toString() at the bytecode level. There is no version of the compiled code where the value can leak through toString() — not in production, not in staging, not in a test environment printing debug output to a shared log collector.
This is the same principle behind other compile-time safety mechanisms: null safety in Kotlin, sealed classes for exhaustive matching, type-safe SQL query builders. The goal is to make the unsafe path structurally unavailable, rather than relying on discipline and review to catch every instance.
Mentions
Code of Conduct
Please refer to the Code of Conduct document.
