GEP-25
|
Abstract
This GEP proposes a single, coherent data-flow security model for Groovy — a small vocabulary of type-qualifier annotations attached to values, a shared registry of sinks (operations that are sensitive endpoints for a qualified value), and a shared set of design principles (opt-in, low-noise, honest about Groovy’s dynamism). On top of that shared model it specifies two deliverables that are designed together but shipped separately:
-
Deliverable A — Compile-time taint tracking (ingress). An opt-in
@TypeCheckedextension,TaintChecker, with the@Tainted/@Untaintedqualifiers, that reports at compile time when untrusted data reaches a dangerous operation (SQL, shell, HTML, file path) without sanitization. This is the classic injection-prevention dataflow analysis. It joins the pluggable checker family ingroovy-typecheckers(NullChecker,RegexChecker,FormatStringChecker,PurityChecker, and the shape checkersCombinerChecker/MonadicChecker). Targets Groovy 7.0. -
Deliverable B — Sensitive-data redaction (egress). A small AST transform, driven by
@Redacted(and the@Sensitivevalue qualifier), that masks sensitive fields (credentials, tokens, PII) when Groovy generates a value’s string form —@ToString,@Canonical,@Immutable, records — so secrets do not leak into logs and diagnostics. This is the dual of taint: instead of untrusted data flowing in to a dangerous operation, sensitive data flows out to an observation point. It is declarative, near-complete, and essentially free of false positives; it can land ahead of the taint checker.
The two directions are two halves of one picture: a security label on a value, a sink it must not reach unguarded, and a boundary (a sanitizer for taint, a mask for redaction) that makes it safe. Designing them under one vocabulary avoids two overlapping annotation systems that would later have to be reconciled, while shipping them separately keeps the cheap, low-risk win from being held hostage to the hard one.
Motivation
Two of the most common ways application code mishandles data are mirror images of each other, and both are dataflow problems:
-
Injection (ingress). Untrusted data flows into a trusting operation without sanitization — CWE-89 SQL injection, CWE-79 cross-site scripting, CWE-78 OS command injection, CWE-22 path traversal. These sit perennially at the top of the OWASP Top Ten. Catching them at compile time — in the same pass as type checking, with the developer’s IDE underlining the offending flow — is materially cheaper than catching them in a separate SAST tool, a code review, or production.
-
Secret leakage (egress). Sensitive data — passwords, API keys, tokens, PII — flows out into logs, stack traces, crash reports, and diagnostics, most often through an object’s auto-generated string form. A single
log.info("created $user")or an exception carrying a populated DTO is enough to spill a credential into a log aggregator, in violation of the most basic secret-hygiene and privacy expectations. The fix is cheap and declarative — mask the sensitive fields at the point strings are generated — but only if the language makes it a one-annotation affair.
Groovy is a frequent host for precisely the code where both matter: build scripts, server-side web
handlers (Grails, Micronaut, Ratpack, raw servlets), SQL via groovy.sql.Sql, markup via
MarkupBuilder and template engines, shell-outs via "cmd".execute(), and the data-carrying classes
(@Canonical / @Immutable DTOs, records) whose toString() Groovy itself generates. A Groovy-native
data-flow security model meets that code where it lives.
The broader trajectory matters too. As AI-assisted security tooling — vulnerability detection, suggested
fixes, automated pull requests — continues to gain momentum, a language that exposes a typed,
machine-checkable substrate for both taint and sensitivity is far better positioned than one that
offers only freeform source to reason about. A model can propose @Tainted / @Untainted / @Redacted
annotations; the compiler then verifies the resulting flow soundly or enforces the masking
mechanically. The model proposes, the compiler disposes. Putting the substrate in place now is what
lets Groovy ride those advances rather than retrofit them later.
Shared design
Both deliverables rest on the same three ideas and the same principles. Fixing them once, here, is the reason GEP-25 is a single proposal rather than two.
One model, two directions
| Ingress — taint (Deliverable A) | Egress — redaction (Deliverable B) | |
|---|---|---|
What the label means |
Provenance: is this value’s origin trusted? ( |
Sensitivity: does this value’s content need protecting? ( |
Direction of flow |
Untrusted data flows inward to a dangerous operation |
Sensitive data flows outward to an observation point |
What a sink is |
A dangerous operation: |
An observation point: |
The boundary that makes it safe |
A sanitizer ( |
A mask ( |
Enforcement |
Verify — report a compile error; the developer must fix the flow |
Transform — rewrite the generated string form so the value is masked at runtime |
Completeness |
Best-effort (Groovy is dynamic); loud where it cannot follow a flow |
Near-complete for the string-generation sinks it owns; declarative, no inference |
The one structural difference worth stating up front: taint is a verification pass (it changes no
runtime behavior; it either compiles or reports), whereas redaction is a code-generation transform
(it changes the string a value produces at runtime). That is why they are different mechanisms in
different modules — TaintChecker is a type-checking extension, redaction is an AST transform in the
@ToString family — even though they share vocabulary and a sink registry. It is also why redaction can
be near-complete while taint cannot: a local transform owns all the string-generation sinks it targets,
whereas whole-program taint tracking runs into Groovy’s dynamic dispatch.
Shared vocabulary
The labels are the extremes of security lattices, kept deliberately minimal so richer external tools can reuse them without their own annotations:
| Annotation | Meaning |
|---|---|
|
Trust lattice, |
|
Sensitivity qualifier: this value carries data that must not appear unmasked at an egress sink. The
dual of |
|
The field/property-level form used by Deliverable B: it marks the property’s value |
Role is determined by position, not by a separate @Source / @Sink / @Sanitizer vocabulary: a
@Tainted return is a source, an @Untainted parameter is a sink, an @Untainted return that accepts
@Tainted input is a sanitizer. See Deliverable A for the full table.
Shared sink registry and model library
Both deliverables draw on one curated model library: qualifier annotations (or external stub files) for the JDK and common Groovy/Java libraries. The registry has two kinds of entry, and some entries serve both deliverables:
-
Dangerous-operation sinks (
Statement,Runtime.exec/ProcessBuilder,Files/Pathconstructors, response writers,MarkupBuilderraw sinks) — consumed by the taint checker. -
Egress / observation sinks (
toString,Object.dump,inspect,InvokerHelper.toString,JsonOutput/JsonBuilder, GString interpolation, common logging facades) — the points at which a@Sensitivevalue leaks. Deliverable B owns the string-generation subset and masks there; the same set lets a strict-mode taint pass warn when a@Sensitivevalue reaches an egress sink that does not mask (for example a rawlog.info("$user")).
The accuracy and currency of this library, not the propagation engine, is the bulk of the work and the bulk of the ongoing maintenance. Sharing it across both deliverables is a further argument for one GEP.
Shared principles
-
Opt-in and composable — nothing here changes the behavior of code that does not ask for it. The taint checker runs only where its extension is enabled; redaction runs only on classes carrying
@Redacted. Both compose with the rest of Groovy’s checker and transform machinery. -
Low-noise / near-zero false positives — a security tool that cries wolf is uninstalled. Deliverable A leans on an optimistic default and GString-awareness to stay quiet (see there); Deliverable B is declarative — you annotate a field, that field is masked, done — so it has essentially no false positives at all.
-
Honest scope, loud where it must skip — Groovy’s dynamic dispatch cannot always be tracked statically. Where a flow cannot be followed, the tooling says so rather than silently passing or failing. Annotations remain meaningful as executable contracts even where static flow is not tracked, which is what lets a runtime fallback (a checked-at-runtime enforcement mode) cover the dynamic gaps that a Scala-style pure-compile-time model would simply miss.
-
Forward-compatible vocabulary —
@Tainted/@Untainted/@Sensitiveare lattice extremes. Richer external analyses (multi-level lattices, value-dependent labels, SMT-backed declassification) can consume the same annotations as the extremes of a finer ordering, so annotating for GEP-25 is never wasted effort, and AI-assisted tooling has a single, stable substrate to target.
Deliverable A — Compile-time taint tracking (ingress)
An optional, opt-in @TypeChecked extension, TaintChecker, with the @Tainted and @Untainted
type-qualifier annotations. Data originating from an untrusted source is flagged @Tainted; the
checker reports a compile error when tainted data reaches a sink that requires @Untainted data without
first passing through a sanitizer. The analysis is a two-point lattice — @Untainted <: @Tainted —
which keeps it a fast, sound, solver-free dataflow pass rather than a heavyweight verification engine.
Two design choices distinguish this from a straight port of existing Java taint checkers. First, the
analysis is aware of Groovy’s own structural mechanisms — most notably GString, which already gives
groovy.sql.Sql safe-by-construction, parameterized queries; the checker recognizes and rewards that
idiom rather than ignoring it. Second, the @Tainted / @Untainted vocabulary is deliberately the two
extremes of a security lattice (see Shared design), so it remains forward-compatible with richer,
external information-flow tools.
The annotations
Two annotations, usable in TYPE_USE position (and on methods, parameters, and fields):
package groovy.transform // package tentative
@Documented @Retention(RUNTIME) @Target([TYPE_USE, METHOD, PARAMETER, FIELD, LOCAL_VARIABLE])
@interface Tainted {}
@Documented @Retention(RUNTIME) @Target([TYPE_USE, METHOD, PARAMETER, FIELD, LOCAL_VARIABLE])
@interface Untainted {}
The qualifier lattice is two points with @Untainted <: @Tainted: an untainted value may be used
wherever a tainted value is accepted, but not the reverse. The role an annotation plays is determined
entirely by where it appears:
| Position | Role |
|---|---|
|
Source — the method yields untrusted data ( |
|
Sink — the method requires trusted data ( |
|
Sanitizer — the method launders taint ( |
|
A tracked value of that qualifier |
There is no separate declassifier annotation: an @Untainted return is the declassification, because
in a two-point world there is only one direction worth naming (tainted → untainted).
Sources, sinks, and the basic flow
@TypeChecked(extensions = ['groovy.typecheckers.TaintChecker'])
class Handler {
@Tainted String userName(HttpServletRequest req) {
req.getParameter('name') // request params are modeled @Tainted
}
void run(Sql sql, HttpServletRequest req) {
@Tainted String name = userName(req)
// ERROR: a @Tainted String reaches a query sink built by concatenation
sql.execute('SELECT * FROM users WHERE name = \'' + name + '\'')
}
}
The diagnostic names the flow, in the style of the other checkers:
[Static type checking] - Tainted value 'name' reaches the untainted parameter of Sql.execute; data from Handler.userName (a @Tainted source) flows here without sanitization
Sanitizers (the only declassification needed)
A method whose return is @Untainted launders taint — the checker trusts the annotation and treats the
result as untainted regardless of input:
@Untainted String escapeHtml(@Tainted String s) { /* ... escaping ... */ }
void render(Writer out, @Tainted String comment) {
out.write(escapeHtml(comment)) // OK: the sink receives untainted data
}
For the rare audited-inline case — "I have validated this value by other means" — an explicit, greppable suppression is provided rather than a silent cast:
@SuppressWarnings('groovy.taint')
@Untainted String trusted = afterMyOwnValidation(raw)
Suppressions are the audit trail: a security reviewer greps groovy.taint to find every point where a
human overrode the analysis.
Propagation and flow sensitivity
Taint propagates through the expression grammar: assignment, string concatenation and GString
interpolation (see below), arithmetic, and method calls (a value derived from tainted arguments is
tainted unless the callee declares an @Untainted return). The analysis is flow-sensitive: a variable
may be tainted at one program point and untainted at a later one after sanitization —
@Tainted String x = source()
sink(x) // ERROR
x = escapeHtml(x) // x is @Untainted from here on
sink(x) // OK
— but it is not value-dependent: a value’s qualifier never depends on the runtime value of other state. That is the property that keeps the analysis a boolean dataflow pass with no solver, and it is the natural boundary at which a deeper external tool would take over (see Shared design).
Pass-through methods and polymorphism
Many methods neither taint nor sanitize — String.trim(), .toLowerCase(), .substring(int) — they
pass taint through. Annotating these @Untainted would leak; annotating them @Tainted would
over-report. A third, polymorphic qualifier, @PolyTainted, expresses "the result’s taint matches the
receiver/argument’s taint":
@PolyTainted String trim(@PolyTainted String self) { ... } // trim(tainted) is tainted; trim(untainted) is untainted
@PolyTainted is what makes the model library for the JDK’s string API usable. It is part of the design
but is presented as a refinement: a first cut can model pass-through methods conservatively (propagate
taint) and add @PolyTainted where over-reporting proves painful.
Leveraging existing Groovy mechanisms
Groovy already encodes injection safety structurally, before any annotation exists, and the checker
should reward that rather than ignore it. The headline case is GString.
groovy.sql.Sql special-cases GString arguments: a query written as an interpolated string is compiled
to a parameterized PreparedStatement, with the interpolated values bound as parameters rather than
concatenated into SQL. The type of the argument — GString versus String — already distinguishes the
safe path from the dangerous one:
sql.execute("SELECT * FROM users WHERE name = ${name}") // GString -> parameterized -> SAFE even if name is @Tainted
sql.execute('SELECT * FROM users WHERE name = ' + name) // String -> concatenated -> ERROR if name is @Tainted
TaintChecker recognizes the GString-accepting Sql methods as built-in sanitizing sinks: the
interpolated holes become bound parameters, so tainted values inside a GString destined for such a sink
are safe, while the same data flattened into a String (via concatenation or .toString()) and passed
to the raw-String overload is reported. This generalizes: a GString carries its structure (fixed
fragments versus interpolated values), and APIs that consume that structure (parameterized queries,
attribute-escaping builders) are inherently safer than their flat-String equivalents. The checker can
be taught these GString-aware APIs out of the box — no new annotations on user code required.
This is a distinctively Groovy advantage: the language’s structured-string type gives the checker a
ready-made, zero-annotation safety boundary that a String-only language does not have.
Defaulting and the soundness / noise trade-off
The default qualifier is @Untainted. String and character literals are untainted; unannotated locals,
parameters, fields, and returns default untainted; the shipped model library marks known external-input
APIs @Tainted and known dangerous APIs as @Untainted-requiring sinks. The consequence is explicit:
-
Optimistic default (proposed) — low false-positive rate; a diagnostic fires only for flows the checker has been told about. The cost is potential false negatives: an un-modeled source is treated as untainted and its flows are missed.
-
Strict mode (flag) — external inputs (and, optionally, all unannotated boundaries) default
@Tainted. Sound, but noisier; appropriate for security-critical code that accepts the annotation burden.
The optimistic default is proposed as the out-of-the-box behavior precisely to protect the checker’s reputation for trustworthiness (see Risks).
Scope and limitations (Deliverable A)
-
Intraprocedural plus same-unit interprocedural — flows are tracked within a method and across calls whose callee is resolvable at compile time (same compilation unit, or modeled in the library). Whole-program, cross-jar taint is out of scope for a type-checker extension.
-
Dynamic dispatch — a call whose target cannot be resolved statically yields a loud "flow not tracked" note rather than a silent verdict. (This is also where the shared runtime-fallback stance matters: the annotations remain contracts a runtime check can enforce.)
-
Explicit flows only (initially) — implicit flows (taint induced by branching on tainted data) are not tracked in the first version; they are a well-known source of noise and are deferred to an opt-in mode. Most real injection bugs are explicit flows.
-
Collections are coarse — a container is tracked with a single taint bit rather than per-element; per-element precision is value-dependent and is exactly the boundary handed off to richer tools.
A worked example — composing with another checker
The value of the checker family is that several run on one compile, each on its own concern:
@TypeChecked(extensions = ['groovy.typecheckers.NullChecker',
'groovy.typecheckers.TaintChecker'])
class Comments {
@Tainted String body(HttpServletRequest req) { req.getParameter('body') }
void post(Sql sql, Writer page, @NonNull HttpServletRequest req) {
@Tainted String raw = body(req)
@Untainted String safe = escapeHtml(raw)
sql.execute("INSERT INTO comments(text) VALUES (${safe})") // GString sink: parameterized, OK
page.write(safe) // untainted to an HTML sink: OK
}
@Untainted String escapeHtml(@Tainted String s) { /* ... */ }
}
NullChecker verifies the request is non-null; TaintChecker verifies no tainted comment reaches the database or the page. Distinct concerns, distinct error channels, one compile.
Implementation (Deliverable A)
TaintChecker is a org.codehaus.groovy.transform.stc.TypeCheckingExtension (the same SPI as the other
checkers). During type checking it:
-
assigns each expression a taint qualifier, propagating along the dataflow rules above;
-
reads
@Tainted/@Untainted/@PolyTaintedon declarations, and the shipped model library, to fix qualifiers at boundaries; -
checks assignments to
@Untaintedtargets and arguments at@Untaintedparameters, reporting a flow that delivers tainted data; -
recognizes the GString-aware safe sinks (e.g.
groovy.sql.Sql) as built-in sanitizers.
The non-trivial deliverable is the shared model library (see Shared design): the dangerous-operation
sinks, sources, sanitizers, and @PolyTainted pass-through string methods. Its accuracy and currency,
not the propagation engine, is the bulk of the work and of the ongoing maintenance.
Deliverable B — Sensitive-data redaction (egress)
A small AST transform that masks sensitive fields when Groovy generates a value’s string form, so credentials, tokens, and PII do not leak into logs, stack traces, and diagnostics. Where Deliverable A verifies that untrusted data never reaches a dangerous operation, Deliverable B transforms the generated string form so sensitive data never appears unmasked at an observation point. It is declarative, has essentially no false positives, and — because Groovy already owns the machinery that generates string forms — is cheap enough to ship ahead of the taint checker.
Why Groovy is well placed for this
The Scala redacted library needs a whole compiler plugin to do this, because Scala does not expose
toString generation as a user-facing annotation. Groovy already generates toString() (and friends)
through the @ToString, @Canonical, @Immutable, and record machinery. Adding a per-field mask is a
small, self-contained extension to transforms that already exist — no new compiler pass, no plugin.
The annotations
package groovy.transform // package tentative
// Field / property level: mark sensitive AND request masking in generated string forms.
@Documented @Retention(RUNTIME) @Target([FIELD, METHOD /* property */])
@interface Redacted {
String value() default '***' // the mask; e.g. '***' or '<redacted>'
}
// Value qualifier (shared vocabulary): this value must not appear unmasked at an egress sink.
@Documented @Retention(RUNTIME) @Target([TYPE_USE, METHOD, PARAMETER, FIELD, LOCAL_VARIABLE])
@interface Sensitive {}
In the common case a user writes only @Redacted on the sensitive property. @Sensitive is the
underlying value qualifier (the dual of @Tainted) that the shared sink registry understands; @Redacted
implies it.
The basic behavior
@Canonical
class Credentials {
String username
@Redacted String password
@Redacted('<hidden>') String apiKey
}
new Credentials('alice', 's3cr3t', 'ak_live_123').toString()
// => Credentials(alice, ***, <hidden>)
The transform substitutes the mask for the field’s value in the generated toString() only. It does
not touch the field’s real value: direct property access, equals / hashCode, serialization, and all
business logic see the true value. This mirrors the one principle that makes Scala redacted safe to
adopt — redaction is about observation, never about the data itself — while keeping the escape hatch
obvious: if you need the real value, you read the property.
Egress sinks covered
Scala redacted covers only case-class toString(). Real leaks in Groovy escape through more doors, and
Deliverable B should cover the ones Groovy itself generates or controls:
| Egress point | First cut | Notes |
|---|---|---|
|
Yes |
The transform owns this code; masking is a direct edit to the generated method |
|
Yes (where Groovy generates the rendering) |
The default renderers Groovy provides for objects and collections |
GString interpolation of a redacted-bearing object — |
Yes (via that object’s masked |
Interpolating the object uses its masked |
|
Deferred / opt-in |
Serialization is sometimes legitimately meant to carry the value; masking here needs an explicit opt-in to avoid surprising round-trips |
Hand-written |
Not covered |
If a class writes its own |
The honest statement of that last row matters: like the taint checker, redaction is loud about what it does not cover rather than implying completeness.
Relationship to Deliverable A
The two deliverables meet at the shared sink registry. A @Redacted field’s value is @Sensitive; the
egress / observation sinks in the registry are the points where a @Sensitive value leaks. Redaction
masks at the string-generation sinks it owns. A strict-mode taint-style pass can additionally use the
same registry to warn when a @Sensitive value reaches an egress sink that does not mask — for example
a raw log.info("password=$password") that interpolates the property directly rather than the object.
That composition is available precisely because both deliverables share one vocabulary and one registry;
neither requires the other to ship.
Scope and limitations (Deliverable B)
-
Groovy-generated string forms only — the transform edits code Groovy generates. Hand-written
toStringand third-party renderers are out of scope (a lint note is the most it offers there). -
No dataflow required — redaction is purely declarative and local to the annotated class, which is why it has no false positives and can be near-complete for the sinks it owns. It does not attempt to track a sensitive value as it is copied into other objects; that is the (harder, deferred)
@Sensitiveflow problem, and is the boundary at which the shared registry and a future strict pass take over. -
Masking is not encryption — the value still lives in memory and in any output the transform does not own. Redaction reduces accidental leakage; it is not a data-at-rest control.
Implementation (Deliverable B)
An AST transform in the @ToString family. When a class carries a generated string form and one or more
@Redacted properties, the transform emits the mask literal in place of the property’s value expression
in the generated toString() (and the other Groovy-owned renderers it is taught). It leaves equals,
hashCode, property accessors, and constructors untouched. Because the annotation is RUNTIME-retained,
it is also available to any renderer that wishes to honor it reflectively (a hook for third-party
libraries and for the strict-mode egress warning).
Prior art
| Tool | Approach | Relationship to this proposal |
|---|---|---|
Checker Framework — Tainting Checker (Java) |
Pluggable type qualifiers |
Closest analogue for Deliverable A; adapts the same two-point model to a Groovy |
Ballerina taint analysis |
Built-in |
Demonstrates a mainstream language shipping taint checking as a first-class, low-ceremony feature |
Scala |
Compiler plugin rewriting case-class |
Direct analogue for Deliverable B. Groovy achieves the same via its existing |
FindSecBugs / SpotBugs, SonarQube |
Bytecode/AST heuristics, separate tool |
Post-hoc and tool-external; this proposal is in-compiler and annotation-directed |
CodeQL, Semgrep |
Whole-program / query-based dataflow |
Deeper and cross-jar, but heavyweight and out-of-band; complementary, not a substitute |
OWASP ESAPI, encoder libraries |
Runtime sanitizers |
The sanitizers Deliverable A’s annotations would mark; the checker enforces that they are called |
Forward compatibility
The @Tainted / @Untainted / @Sensitive qualifiers are the extremes of security lattices. This keeps
the vocabulary open-ended:
-
Richer information-flow tools — an external analysis offering multi-level lattices, value-dependent labels, or proof-backed declassification (for example an SMT-backed verifier such as
groovy-verify) can consume the same annotations as the extremes of a finer ordering. Code annotated for GEP-25 remains meaningful to a deeper analysis with no re-annotation, and the two compose cleanly. -
AI-assisted security — as automated vulnerability detection and repair mature, the annotation layer is the natural integration point: a tool (or model) proposes source / sink / sanitizer / redaction annotations, and the compiler turns those proposals into checked guarantees (Deliverable A) or enforced masking (Deliverable B) rather than unverified suggestions.
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Implicit (control-flow) taint |
Deferred (opt-in) |
A known source of false positives; most injection bugs are explicit flows |
Per-element collection taint |
Deferred |
Value-dependent; the boundary at which a richer external tool takes over |
Whole-program / cross-jar tracking |
Not planned |
Out of scope for a type-checker extension; CodeQL/Semgrep territory |
|
Deferred |
The harder egress counterpart to taint flow; Deliverable B masks at generated sinks without it |
Redaction of serializers (JSON/XML) and hand-written |
Deferred / out of scope |
Serialization may legitimately carry the value (needs opt-in); hand-written renderers are the author’s responsibility (lint note only) |
Configurable multi-level lattices |
Not planned (here) |
Two-point checkers need none; multi-level is left to external tools sharing the vocabulary |
|
Refinement |
Part of Deliverable A; a first cut may model pass-through methods conservatively and add it incrementally |
Strict (sound-by-default) mode; strict egress warnings |
Behind a flag |
Available for security-critical code that accepts the annotation burden |
Compatibility
Backwards compatibility
Both deliverables are entirely opt-in and additive. No existing program changes behavior:
-
Deliverable A runs only where
@TypeChecked(extensions = ['groovy.typecheckers.TaintChecker'])is applied; absent that, nothing changes.@Tainted/@UntaintedareRUNTIME-retained markers ignored by code that does not enable the checker. -
Deliverable B runs only on classes that carry
@Redacted; a class without it generates exactly the string form it does today.@Redacted/@SensitiveareRUNTIME-retained markers with no effect elsewhere. -
The annotations ship in the existing modules (
groovy-typecheckersfor the checker; the core transform package for redaction); core Groovy semantics are untouched.
Interaction with other checkers and transforms
TaintChecker composes with the other extensions on a single @TypeChecked declaration, each reporting
on its own concern. Redaction composes with the @ToString family it extends, honoring existing
@ToString options (includes, excludes, includeNames, and so on) and simply substituting the mask
for redacted properties.
Risks, maintenance, and status
This GEP is deliberately frank about why it is a placeholder rather than a committed deliverable, and the risk profiles of the two deliverables differ.
-
Taint checkers carry a false-positive / maintenance reputation (Deliverable A). Tools in this space are routinely uninstalled because they are too noisy, or quietly distrusted because they are too lax. Deliverable A leans hard on the low-noise default, GString-awareness, explicit greppable suppression, and loud skips to earn and keep developer trust — but the reputational risk is real and must be managed by conservative defaults and good diagnostics, not wished away.
-
The model library is an ongoing commitment (shared). The propagation engine is modest; the durable cost is curating and keeping current the source / sink / sanitizer registry for the JDK and the common Groovy/Java ecosystem. Both deliverables draw on it. This is the part the Groovy team would need to be comfortable owning (or governing as a community-maintained, versioned artifact) before Deliverable A ships. An out-of-date library is itself a source of both false negatives and false positives.
-
Redaction is low-risk but not a panacea (Deliverable B). Being declarative and local, it has essentially no false positives and modest maintenance. Its risk is the opposite — a false sense of completeness. It masks the string forms Groovy generates; it does not stop a value leaking through a hand-written
toString, a serializer it was not told to cover, or code that reads the property directly. The documentation must be explicit that redaction reduces accidental leakage rather than guaranteeing secrecy. -
Soundness is a chosen posture, not a guarantee (Deliverable A). The optimistic default is explicitly unsound (it can miss un-modeled sources) in exchange for trustworthiness. Users who need soundness opt into strict mode and its noise.
-
Scope is bounded and honest (both). Dynamic dispatch, cross-jar flows, implicit flows, per-element precision, hand-written renderers, and
@Sensitiveflow tracking are out of scope or deferred; the tooling says so rather than pretending otherwise.
Accordingly the status is Draft (placeholder). The near-term purpose is to fix the shared annotation
vocabulary (@Tainted / @Untainted / @Sensitive / @Redacted, and the role-by-position convention)
so Groovy 6.x can align to it, and to gauge appetite and maintenance commitment. Because it is small,
declarative, and low-risk, Deliverable B (redaction) is the candidate to land first — potentially in a
Groovy 6.x point release — while Deliverable A (taint checking) targets Groovy 7.0, conditional on
committed maintenance for the model library. The umbrella-plus-two-tracks split is proposed pending team
review.