GEP-25


Metadata
Number

GEP-25

Title

Data-flow Security — Taint Tracking and Sensitive-data Redaction

Version

2

Type

Feature

Status

Draft (placeholder)

Leader

Paul King

Created

2026-06-17

Last modification

2026-07-01

Note
WARNING: This proposal is an early placeholder. It stakes out a shared design and, more importantly, an annotation vocabulary, so that the syntax can be aligned in Groovy 6.x ahead of any implementation and so the community can gauge appetite. GEP-25 is an umbrella: it defines one data-flow security model and then specifies two independent deliverables that share that model — a compile-time taint checker (ingress) and a sensitive-data redaction transform (egress). The two are deliberately decoupled: they have opposite risk and maturity profiles and can ship on their own timelines. We are currently working on Groovy 6.0. The redaction deliverable is small and could land in a Groovy 6.x point release; the taint checker targets Groovy 7.0 and is only worth shipping if the project is comfortable with the ongoing maintenance it implies (see Risks, maintenance, and status). This draft is deliberately honest about that. We welcome feedback and discussion, but the details are not finalized and either deliverable may not proceed at all without committed maintainers.

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 @TypeChecked extension, TaintChecker, with the @Tainted / @Untainted qualifiers, 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 in groovy-typecheckers (NullChecker, RegexChecker, FormatStringChecker, PurityChecker, and the shape checkers CombinerChecker / MonadicChecker). Targets Groovy 7.0.

  • Deliverable B — Sensitive-data redaction (egress). A small AST transform, driven by @Redacted (and the @Sensitive value 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? (@Untainted) or untrusted (@Tainted)

Sensitivity: does this value’s content need protecting? (@Sensitive / @Redacted)

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: Sql.execute, Runtime.exec, response writer, Path constructor

An observation point: toString, dump, inspect, GString interpolation, JSON output, a logger

The boundary that makes it safe

A sanitizer (escapeHtml) that returns @Untainted

A mask (*) substituted at the egress sink

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

@Tainted / @Untainted

Trust lattice, @Untainted <: @Tainted. Used by Deliverable A. (@PolyTainted expresses pass-through; see Deliverable A.)

@Sensitive

Sensitivity qualifier: this value carries data that must not appear unmasked at an egress sink. The dual of @Tainted. Default is non-sensitive.

@Redacted

The field/property-level form used by Deliverable B: it marks the property’s value @Sensitive and instructs the string-generation transforms to mask it. In the common case this is the only annotation a user writes for redaction.

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 / Path constructors, response writers, MarkupBuilder raw 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 @Sensitive value leaks. Deliverable B owns the string-generation subset and masks there; the same set lets a strict-mode taint pass warn when a @Sensitive value reaches an egress sink that does not mask (for example a raw log.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 / @Sensitive are 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

@Tainted return type

Source — the method yields untrusted data (request.getParameter(…​))

@Untainted parameter

Sink — the method requires trusted data (Statement.executeQuery(String))

@Untainted return, @Tainted (or any) input

Sanitizer — the method launders taint (escapeHtml(String))

@Tainted / @Untainted on a field, local, or value

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 / @PolyTainted on declarations, and the shipped model library, to fix qualifiers at boundaries;

  • checks assignments to @Untainted targets and arguments at @Untainted parameters, 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

@ToString / @Canonical / @Immutable / record toString()

Yes

The transform owns this code; masking is a direct edit to the generated method

Object.dump(), inspect(), InvokerHelper.toString

Yes (where Groovy generates the rendering)

The default renderers Groovy provides for objects and collections

GString interpolation of a redacted-bearing object — "$creds"

Yes (via that object’s masked toString)

Interpolating the object uses its masked toString; interpolating the raw password property cannot be masked and is a candidate for a strict-mode warning (see below)

JsonOutput / JsonBuilder, other serializers

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 toString()

Not covered

If a class writes its own toString, the transform does not rewrite it (same limitation as Scala redacted); the class author is responsible. A lint note may flag a @Redacted field whose class has a hand-written toString

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 toString and 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) @Sensitive flow 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 @Tainted/@Untainted/@PolyTainted, compile-time, sound

Closest analogue for Deliverable A; adapts the same two-point model to a Groovy @TypeChecked extension and adds GString-awareness

Ballerina taint analysis

Built-in tainted/untainted with compiler-enforced flow

Demonstrates a mainstream language shipping taint checking as a first-class, low-ceremony feature

Scala polentino/redacted

Compiler plugin rewriting case-class toString() to mask @redacted fields

Direct analogue for Deliverable B. Groovy achieves the same via its existing @ToString-family transforms (no plugin), and extends coverage beyond toString to Groovy’s other generated egress points

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

@Sensitive flow tracking (sensitive value copied across objects)

Deferred

The harder egress counterpart to taint flow; Deliverable B masks at generated sinks without it

Redaction of serializers (JSON/XML) and hand-written toString

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

@PolyTainted polymorphism

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 / @Untainted are RUNTIME-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 / @Sensitive are RUNTIME-retained markers with no effect elsewhere.

  • The annotations ship in the existing modules (groovy-typecheckers for 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 @Sensitive flow 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.