[
https://issues.apache.org/jira/browse/GROOVY-12244?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12244:
-------------------------------
Description:
h2. Summary up front
This is about *consistency of treatment*, not about catching more code.
Someone who configures {{disallowedReceivers = ['java.lang.System']}} holds a
single expectation: System calls are rejected in the source they compile. After
GROOVY-12238 that expectation holds everywhere - method bodies, constructor
bodies, static and instance initializers, field initializers, closures
relocated into a generated constructor by {{@TupleConstructor(pre=...)}},
groovy-contracts conditions inlined into loop bodies - with exactly one
exception: {{@ConditionalInterrupt}}.
Nothing visible to the user explains the exception. It exists because that
transformation relocates its closure into a *synthetic* method while the others
relocate into constructors, ordinary methods or generated classes, and the
customizer skips synthetic methods. That is an implementation detail of one
transformation leaking into observable behaviour, and no user could be expected
to predict it.
Measured across 1632 real files the change produces *zero false positives*,
with the checks firing 78 times throughout. It produced no new catches on that
corpus, because Groovy's test tree happens to contain no case combining
{{@ConditionalInterrupt}} with a restriction - but the case for the change does
not rest on catching more code, it rests on the rule being uniform.
Depends on GROOVY-12238, which introduces the two helpers it reuses.
h2. Problem
{{SecureASTCustomizer}} skips synthetic methods when visiting method bodies:
{code:java}
for (MethodNode methodNode : clNode.getMethods()) {
if (!methodNode.isSynthetic() && methodNode.getCode() != null) {
methodNode.getCode().visit(visitor);
}
}
{code}
That is right for compiler-generated members, but a transformation may relocate
code the *user wrote* into a synthetic method.
{{ConditionalInterruptibleASTTransformation}} does exactly this - it lifts the
closure supplied to {{@ConditionalInterrupt}} into a private synthetic method
and injects calls to it at every method start and every loop:
{code:groovy}
type.addSyntheticMethod(conditionMethod, ACC_PRIVATE, ClassHelper.OBJECT_TYPE,
Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, conditionNode.code)
{code}
So with {{disallowedReceivers = ['java.lang.System']}} configured, this
compiles and runs today:
{code:groovy}
import groovy.transform.ConditionalInterrupt
@ConditionalInterrupt({ System.getProperty('x') != null })
class A { def m() { 1 } }
{code}
while the same call written directly in a method body is correctly rejected.
This is the same relocation story as GROOVY-12238, one destination further on.
There, authored code moved into a generated *constructor* escaped the checks;
here it is a synthetic *method*. Dumping the AST at CANONICALIZATION shows the
code arrives with its source position intact, exactly as in the constructor
case:
{noformat}
@ConditionalInterrupt({ System.getProperty('x') != null })
method conditionalTransform...$condition line=-1 synthetic=true codeLine=2
stmt[0] line=2 (java.lang.System.getProperty(x) != null) <- authored,
relocated here
{noformat}
Set against the other transformations which relocate a user-supplied closure,
{{@ConditionalInterrupt}} is the only one left uncovered:
||Transformation||Where the authored closure lands||Checked after
GROOVY-12238?||
|{{@TupleConstructor(pre/post)}}, {{@MapConstructor(pre/post)}}|generated
constructor|yes|
|{{@AutoImplement(code)}}|generated method|yes|
|groovy-contracts {{@Requires}}/{{@Ensures}}/{{@Invariant}}|generated closure
class added to the module|yes|
|groovy-contracts loop {{@Invariant}}/{{@Decreases}}|inlined into the loop
body|yes|
|*{{@ConditionalInterrupt}}*|*synthetic method*|*no*|
|{{@ASTTest}}|nowhere in the AST - rebuilt from raw source text|no, and
unreachable by any AST-level check; it has its own off-switch,
{{groovy.asttest.enable}}|
Each destination was chosen for a good reason - {{@ConditionalInterrupt}}
injects at every method start and every loop, so one method called many times
is cheaper than inlining the condition dozens of times per class. The point is
not that the transformation is wrong, but that its choice is invisible to
whoever configured the customizer.
h2. Change
About six lines, reusing {{visitAuthoredStatementsOf}} and {{isFromSource}}
from GROOVY-12238:
{code:java}
for (MethodNode method : clNode.getMethods()) {
if (method.isSynthetic() && !"<clinit>".equals(method.getName()) &&
method.getCode() != null) {
visitAuthoredStatementsOf(method.getCode(), visitor);
}
}
{code}
The per-statement source-position filter is what makes this safe: a synthetic
method's *generated* statements carry no source position and are skipped, while
relocated authored statements carry one and are checked. {{<clinit>}} is
excluded because GROOVY-12238 already handles it.
h2. Evidence
*Exposure is far narrower than "synthetic" suggests.* Nine common constructs
were scanned for synthetic methods containing source-positioned statements -
trait implementations, {{@Delegate}}, records, enums, plain properties,
closures in methods, {{@Immutable}}, {{@Sortable}}, {{@ConditionalInterrupt}}.
Only {{@ConditionalInterrupt}} produced one. Generated accessors, delegate
forwarders, record components, enum machinery and trait bridges all carry
unpositioned statements, so the filter excludes them without needing to
enumerate them. Notably, a trait's method body is *not* copied onto the
implementing class as a source-positioned synthetic method, so there is no
duplicate-checking problem there.
*Corpus: zero false positives; no new catches, for want of a case to catch.*
Every {{.groovy}} file under {{src/test}} (1632 files; 22 using
{{@Grab}}/{{@Grapes}} excluded) compiled under a {{SecureASTCustomizer}} with
{{disallowedReceivers = ['java.lang.System', 'java.lang.Thread',
'java.lang.Runtime', 'java.lang.ProcessBuilder']}}, on top of GROOVY-12238,
with and without this change:
||Corpus outcome||Without||With||
|Compiled|1554|1554|
|Rejected|78|78|
Byte-identical. The 78 rejections show the checks were live throughout, so the
unchanged verdicts mean the change was exercised and stayed quiet. But no file
in the corpus was newly caught either: the three files using
{{@ConditionalInterrupt}} have conditions that do not touch the restricted
receivers, so the corpus could not demonstrate an upside.
*Hand-written case:* {{@ConditionalInterrupt}} with a disallowed receiver is
permitted without the change and rejected with it. Full test suite passes.
h2. Is it worth doing
The case for it is consistency. A user configuring the customizer has no way to
know that one transformation relocates into a synthetic method while its
neighbours relocate into constructors, generated methods, generated classes or
the loop body itself, and that the customizer looks in every one of those but
the first. Uniform treatment is what makes the configuration comprehensible; an
exception that cannot be predicted from the source is the kind of surprise
THREAT_MODEL.md section 3 identifies as worth acting on, whether or not it is
common.
It also generalises. The fix catches *any* transformation that relocates
authored code into a synthetic method, including third-party ones that will
never be reviewed here, which is the property that a per-transformation fix
would not have.
The case against is that one first-party annotation is known to benefit, and
there is no evidence that anyone combines {{@ConditionalInterrupt}} with
{{SecureASTCustomizer}} today.
That objection is worth weighing carefully, because it is an absence of
evidence rather than evidence of absence: the corpus is Groovy's own test tree,
never written to exercise that combination, so it could not have shown a
benefit however useful the change is. But the consistency argument does not
depend on the answer. Even at zero current users, the rule "restrictions apply
to the code you wrote" either holds uniformly or it does not, and today it does
not.
h2. Why not change the transform instead
Making the condition method non-synthetic would remove the need for this
change, and looks like a one-word fix. It is not:
* {{isSynthetic()}} is load-bearing in three places in
{{ConditionalInterruptibleASTTransformation}} - the injection-eligibility
check, the traversal check, and a defensive guard on the condition method
itself. Two of them are what stop the condition method being instrumented with
its own interrupt check.
* {{filterMethods}} excludes synthetic methods, so a non-synthetic condition
method would count as a *method definition*. Any class using
{{@ConditionalInterrupt}} would then be rejected under
{{methodDefinitionAllowed = false}} - a new false positive in
{{SecureASTCustomizer}}, created by trying to make code visible to
{{SecureASTCustomizer}}.
More broadly, the transform is not doing anything wrong. It relocates authored
code into the AST with its source position intact, which is the convention
documented in {{ARCHITECTURE.md}} and the user guide; the customizer is simply
not looking there. Fixing the consumer covers every transformation at once,
whereas changing this transformation fixes one and leaves the next one silently
uncovered.
h2. Scope note
{{SecureASTCustomizer}} is a best-effort grammar filter, not a security
boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This is hardening which
removes surprising behaviour; it does not alter that position, and a
demonstrated bypass remains by design rather than a vulnerability.
was:
h2. Summary up front
This is a small, safe change whose value is unproven rather than disproven.
Measured across 1632 real files it produced *zero false positives*, with the
checks firing 78 times throughout, so the safety evidence is solid. It produced
*no new catches* on that corpus either - but that measures the corpus, not the
change: Groovy's test tree happens to contain no case combining
{{@ConditionalInterrupt}} with a {{SecureASTCustomizer}} restriction, and a
corpus can only show a benefit for a pattern it actually contains. The
mechanism is demonstrated by a hand-written case, and adding one such file to
the corpus would show the benefit immediately.
So the corpus is strong evidence about safety and weak evidence about need.
Whether this is worth shipping turns on how often that combination occurs in
practice, which no measurement here settles; see "Is it worth doing" below.
Depends on GROOVY-12238, which introduces the two helpers it reuses.
h2. Problem
{{SecureASTCustomizer}} skips synthetic methods when visiting method bodies:
{code:java}
for (MethodNode methodNode : clNode.getMethods()) {
if (!methodNode.isSynthetic() && methodNode.getCode() != null) {
methodNode.getCode().visit(visitor);
}
}
{code}
That is right for compiler-generated members, but a transformation may relocate
code the *user wrote* into a synthetic method.
{{ConditionalInterruptibleASTTransformation}} does exactly this - it lifts the
closure supplied to {{@ConditionalInterrupt}} into a private synthetic method
and injects calls to it at every method start and every loop:
{code:groovy}
type.addSyntheticMethod(conditionMethod, ACC_PRIVATE, ClassHelper.OBJECT_TYPE,
Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, conditionNode.code)
{code}
So with {{disallowedReceivers = ['java.lang.System']}} configured, this
compiles and runs today:
{code:groovy}
import groovy.transform.ConditionalInterrupt
@ConditionalInterrupt({ System.getProperty('x') != null })
class A { def m() { 1 } }
{code}
while the same call written directly in a method body is correctly rejected.
This is the same relocation story as GROOVY-12238, one destination further on.
There, authored code moved into a generated *constructor* escaped the checks;
here it is a synthetic *method*. Dumping the AST at CANONICALIZATION shows the
code arrives with its source position intact, exactly as in the constructor
case:
{noformat}
@ConditionalInterrupt({ System.getProperty('x') != null })
method conditionalTransform...$condition line=-1 synthetic=true codeLine=2
stmt[0] line=2 (java.lang.System.getProperty(x) != null) <- authored,
relocated here
{noformat}
h2. Change
About six lines, reusing {{visitAuthoredStatementsOf}} and {{isFromSource}}
from GROOVY-12238:
{code:java}
for (MethodNode method : clNode.getMethods()) {
if (method.isSynthetic() && !"<clinit>".equals(method.getName()) &&
method.getCode() != null) {
visitAuthoredStatementsOf(method.getCode(), visitor);
}
}
{code}
The per-statement source-position filter is what makes this safe: a synthetic
method's *generated* statements carry no source position and are skipped, while
relocated authored statements carry one and are checked. {{<clinit>}} is
excluded because GROOVY-12238 already handles it.
h2. Evidence
*Exposure is far narrower than "synthetic" suggests.* Nine common constructs
were scanned for synthetic methods containing source-positioned statements -
trait implementations, {{@Delegate}}, records, enums, plain properties,
closures in methods, {{@Immutable}}, {{@Sortable}}, {{@ConditionalInterrupt}}.
Only {{@ConditionalInterrupt}} produced one. Generated accessors, delegate
forwarders, record components, enum machinery and trait bridges all carry
unpositioned statements, so the filter excludes them without needing to
enumerate them. Notably, a trait's method body is *not* copied onto the
implementing class as a source-positioned synthetic method, so there is no
duplicate-checking problem there.
*Corpus: zero false positives; no new catches, for want of a case to catch.*
Every {{.groovy}} file under {{src/test}} (1632 files; 22 using
{{@Grab}}/{{@Grapes}} excluded) compiled under a {{SecureASTCustomizer}} with
{{disallowedReceivers = ['java.lang.System', 'java.lang.Thread',
'java.lang.Runtime', 'java.lang.ProcessBuilder']}}, on top of GROOVY-12238,
with and without this change:
||Corpus outcome||Without||With||
|Compiled|1554|1554|
|Rejected|78|78|
Byte-identical. The 78 rejections show the checks were live throughout, so the
unchanged verdicts mean the change was exercised and stayed quiet. But no file
in the corpus was newly caught either: the three files using
{{@ConditionalInterrupt}} have conditions that do not touch the restricted
receivers, so the corpus could not demonstrate an upside.
*Hand-written case:* {{@ConditionalInterrupt}} with a disallowed receiver is
permitted without the change and rejected with it. Full test suite passes.
h2. Is it worth doing
Arguments for: it is small, it reuses machinery already being added, it
completes the relocation story rather than leaving one destination uncovered,
and it generalises - it catches *any* transformation that relocates authored
code into a synthetic method, including third-party ones that will never be
reviewed here.
Arguments against: exactly one first-party annotation is known to benefit, and
there is no evidence that anyone combines {{@ConditionalInterrupt}} with
{{SecureASTCustomizer}} in practice.
Note the shape of that second argument: it is an absence of evidence, not
evidence of absence. The corpus used here is Groovy's own test tree, which was
never written to exercise that combination, so it could not have shown a
benefit however useful the change is. Someone with a view on whether the
combination occurs in the wild should decide this; no measurement here settles
it.
h2. Why not change the transform instead
Making the condition method non-synthetic would remove the need for this
change, and looks like a one-word fix. It is not:
* {{isSynthetic()}} is load-bearing in three places in
{{ConditionalInterruptibleASTTransformation}} - the injection-eligibility
check, the traversal check, and a defensive guard on the condition method
itself. Two of them are what stop the condition method being instrumented with
its own interrupt check.
* {{filterMethods}} excludes synthetic methods, so a non-synthetic condition
method would count as a *method definition*. Any class using
{{@ConditionalInterrupt}} would then be rejected under
{{methodDefinitionAllowed = false}} - a new false positive in
{{SecureASTCustomizer}}, created by trying to make code visible to
{{SecureASTCustomizer}}.
More broadly, the transform is not doing anything wrong. It relocates authored
code into the AST with its source position intact, which is the convention
documented in {{ARCHITECTURE.md}} and the user guide; the customizer is simply
not looking there. Fixing the consumer covers every transformation at once,
whereas changing this transformation fixes one and leaves the next one silently
uncovered.
h2. Scope note
{{SecureASTCustomizer}} is a best-effort grammar filter, not a security
boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This is hardening which
removes surprising behaviour; it does not alter that position, and a
demonstrated bypass remains by design rather than a vulnerability.
> SecureASTCustomizer does not check authored code relocated into a synthetic
> method
> ----------------------------------------------------------------------------------
>
> Key: GROOVY-12244
> URL: https://issues.apache.org/jira/browse/GROOVY-12244
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
> h2. Summary up front
> This is about *consistency of treatment*, not about catching more code.
> Someone who configures {{disallowedReceivers = ['java.lang.System']}} holds a
> single expectation: System calls are rejected in the source they compile.
> After GROOVY-12238 that expectation holds everywhere - method bodies,
> constructor bodies, static and instance initializers, field initializers,
> closures relocated into a generated constructor by
> {{@TupleConstructor(pre=...)}}, groovy-contracts conditions inlined into loop
> bodies - with exactly one exception: {{@ConditionalInterrupt}}.
> Nothing visible to the user explains the exception. It exists because that
> transformation relocates its closure into a *synthetic* method while the
> others relocate into constructors, ordinary methods or generated classes, and
> the customizer skips synthetic methods. That is an implementation detail of
> one transformation leaking into observable behaviour, and no user could be
> expected to predict it.
> Measured across 1632 real files the change produces *zero false positives*,
> with the checks firing 78 times throughout. It produced no new catches on
> that corpus, because Groovy's test tree happens to contain no case combining
> {{@ConditionalInterrupt}} with a restriction - but the case for the change
> does not rest on catching more code, it rests on the rule being uniform.
> Depends on GROOVY-12238, which introduces the two helpers it reuses.
> h2. Problem
> {{SecureASTCustomizer}} skips synthetic methods when visiting method bodies:
> {code:java}
> for (MethodNode methodNode : clNode.getMethods()) {
> if (!methodNode.isSynthetic() && methodNode.getCode() != null) {
> methodNode.getCode().visit(visitor);
> }
> }
> {code}
> That is right for compiler-generated members, but a transformation may
> relocate code the *user wrote* into a synthetic method.
> {{ConditionalInterruptibleASTTransformation}} does exactly this - it lifts
> the closure supplied to {{@ConditionalInterrupt}} into a private synthetic
> method and injects calls to it at every method start and every loop:
> {code:groovy}
> type.addSyntheticMethod(conditionMethod, ACC_PRIVATE, ClassHelper.OBJECT_TYPE,
> Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, conditionNode.code)
> {code}
> So with {{disallowedReceivers = ['java.lang.System']}} configured, this
> compiles and runs today:
> {code:groovy}
> import groovy.transform.ConditionalInterrupt
> @ConditionalInterrupt({ System.getProperty('x') != null })
> class A { def m() { 1 } }
> {code}
> while the same call written directly in a method body is correctly rejected.
> This is the same relocation story as GROOVY-12238, one destination further
> on. There, authored code moved into a generated *constructor* escaped the
> checks; here it is a synthetic *method*. Dumping the AST at CANONICALIZATION
> shows the code arrives with its source position intact, exactly as in the
> constructor case:
> {noformat}
> @ConditionalInterrupt({ System.getProperty('x') != null })
> method conditionalTransform...$condition line=-1 synthetic=true codeLine=2
> stmt[0] line=2 (java.lang.System.getProperty(x) != null) <- authored,
> relocated here
> {noformat}
> Set against the other transformations which relocate a user-supplied closure,
> {{@ConditionalInterrupt}} is the only one left uncovered:
> ||Transformation||Where the authored closure lands||Checked after
> GROOVY-12238?||
> |{{@TupleConstructor(pre/post)}}, {{@MapConstructor(pre/post)}}|generated
> constructor|yes|
> |{{@AutoImplement(code)}}|generated method|yes|
> |groovy-contracts {{@Requires}}/{{@Ensures}}/{{@Invariant}}|generated closure
> class added to the module|yes|
> |groovy-contracts loop {{@Invariant}}/{{@Decreases}}|inlined into the loop
> body|yes|
> |*{{@ConditionalInterrupt}}*|*synthetic method*|*no*|
> |{{@ASTTest}}|nowhere in the AST - rebuilt from raw source text|no, and
> unreachable by any AST-level check; it has its own off-switch,
> {{groovy.asttest.enable}}|
> Each destination was chosen for a good reason - {{@ConditionalInterrupt}}
> injects at every method start and every loop, so one method called many times
> is cheaper than inlining the condition dozens of times per class. The point
> is not that the transformation is wrong, but that its choice is invisible to
> whoever configured the customizer.
> h2. Change
> About six lines, reusing {{visitAuthoredStatementsOf}} and {{isFromSource}}
> from GROOVY-12238:
> {code:java}
> for (MethodNode method : clNode.getMethods()) {
> if (method.isSynthetic() && !"<clinit>".equals(method.getName()) &&
> method.getCode() != null) {
> visitAuthoredStatementsOf(method.getCode(), visitor);
> }
> }
> {code}
> The per-statement source-position filter is what makes this safe: a synthetic
> method's *generated* statements carry no source position and are skipped,
> while relocated authored statements carry one and are checked. {{<clinit>}}
> is excluded because GROOVY-12238 already handles it.
> h2. Evidence
> *Exposure is far narrower than "synthetic" suggests.* Nine common constructs
> were scanned for synthetic methods containing source-positioned statements -
> trait implementations, {{@Delegate}}, records, enums, plain properties,
> closures in methods, {{@Immutable}}, {{@Sortable}},
> {{@ConditionalInterrupt}}. Only {{@ConditionalInterrupt}} produced one.
> Generated accessors, delegate forwarders, record components, enum machinery
> and trait bridges all carry unpositioned statements, so the filter excludes
> them without needing to enumerate them. Notably, a trait's method body is
> *not* copied onto the implementing class as a source-positioned synthetic
> method, so there is no duplicate-checking problem there.
> *Corpus: zero false positives; no new catches, for want of a case to catch.*
> Every {{.groovy}} file under {{src/test}} (1632 files; 22 using
> {{@Grab}}/{{@Grapes}} excluded) compiled under a {{SecureASTCustomizer}} with
> {{disallowedReceivers = ['java.lang.System', 'java.lang.Thread',
> 'java.lang.Runtime', 'java.lang.ProcessBuilder']}}, on top of GROOVY-12238,
> with and without this change:
> ||Corpus outcome||Without||With||
> |Compiled|1554|1554|
> |Rejected|78|78|
> Byte-identical. The 78 rejections show the checks were live throughout, so
> the unchanged verdicts mean the change was exercised and stayed quiet. But no
> file in the corpus was newly caught either: the three files using
> {{@ConditionalInterrupt}} have conditions that do not touch the restricted
> receivers, so the corpus could not demonstrate an upside.
> *Hand-written case:* {{@ConditionalInterrupt}} with a disallowed receiver is
> permitted without the change and rejected with it. Full test suite passes.
> h2. Is it worth doing
> The case for it is consistency. A user configuring the customizer has no way
> to know that one transformation relocates into a synthetic method while its
> neighbours relocate into constructors, generated methods, generated classes
> or the loop body itself, and that the customizer looks in every one of those
> but the first. Uniform treatment is what makes the configuration
> comprehensible; an exception that cannot be predicted from the source is the
> kind of surprise THREAT_MODEL.md section 3 identifies as worth acting on,
> whether or not it is common.
> It also generalises. The fix catches *any* transformation that relocates
> authored code into a synthetic method, including third-party ones that will
> never be reviewed here, which is the property that a per-transformation fix
> would not have.
> The case against is that one first-party annotation is known to benefit, and
> there is no evidence that anyone combines {{@ConditionalInterrupt}} with
> {{SecureASTCustomizer}} today.
> That objection is worth weighing carefully, because it is an absence of
> evidence rather than evidence of absence: the corpus is Groovy's own test
> tree, never written to exercise that combination, so it could not have shown
> a benefit however useful the change is. But the consistency argument does not
> depend on the answer. Even at zero current users, the rule "restrictions
> apply to the code you wrote" either holds uniformly or it does not, and today
> it does not.
> h2. Why not change the transform instead
> Making the condition method non-synthetic would remove the need for this
> change, and looks like a one-word fix. It is not:
> * {{isSynthetic()}} is load-bearing in three places in
> {{ConditionalInterruptibleASTTransformation}} - the injection-eligibility
> check, the traversal check, and a defensive guard on the condition method
> itself. Two of them are what stop the condition method being instrumented
> with its own interrupt check.
> * {{filterMethods}} excludes synthetic methods, so a non-synthetic condition
> method would count as a *method definition*. Any class using
> {{@ConditionalInterrupt}} would then be rejected under
> {{methodDefinitionAllowed = false}} - a new false positive in
> {{SecureASTCustomizer}}, created by trying to make code visible to
> {{SecureASTCustomizer}}.
> More broadly, the transform is not doing anything wrong. It relocates
> authored code into the AST with its source position intact, which is the
> convention documented in {{ARCHITECTURE.md}} and the user guide; the
> customizer is simply not looking there. Fixing the consumer covers every
> transformation at once, whereas changing this transformation fixes one and
> leaves the next one silently uncovered.
> h2. Scope note
> {{SecureASTCustomizer}} is a best-effort grammar filter, not a security
> boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This is hardening which
> removes surprising behaviour; it does not alter that position, and a
> demonstrated bypass remains by design rather than a vulnerability.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)