Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e9e232e
wip: implement core taint tracking
fglock Aug 9, 2026
2cd7569
feat: support Perl taint probes
fglock Aug 9, 2026
08d1c3d
fix: preserve given topic and result semantics
fglock Aug 9, 2026
73f900d
feat: propagate taint through scalar transforms
fglock Aug 9, 2026
9c0ce2b
feat: enforce taint checks for file mutations
fglock Aug 9, 2026
e685261
feat: preserve regex taint semantics
fglock Aug 9, 2026
eef32f6
feat: propagate taint through substitutions
fglock Aug 9, 2026
59ee5aa
fix: preserve taint from stringification overloads
fglock Aug 9, 2026
fcb3d23
feat: validate taint-sensitive process environment
fglock Aug 9, 2026
3b1e290
fix: retain taint when materializing capture proxies
fglock Aug 9, 2026
cba2263
feat: taint external runtime inputs at their source
fglock Aug 9, 2026
70c852c
fix: preserve taint on post-increment results
fglock Aug 9, 2026
a65a581
fix: return undef from failed sysopen
fglock Aug 9, 2026
1bae3bb
feat: enforce taint checks for ioctl and fcntl
fglock Aug 9, 2026
4dc5592
feat: propagate taint into the formline accumulator
fglock Aug 9, 2026
c992055
fix: retain tainted repeat provenance for formline pictures
fglock Aug 9, 2026
0b18019
fix: preserve taint on tied container keys
fglock Aug 9, 2026
683d2a2
fix: propagate dynamic method taint to AUTOLOAD
fglock Aug 9, 2026
d4104d9
fix: preserve taint when dereferencing regex values
fglock Aug 9, 2026
0223689
feat: reject insecure tainted regex constructs
fglock Aug 9, 2026
ada6cb1
fix: smartmatch tainted scalars against arrays
fglock Aug 9, 2026
fb4ac85
fix: retain indirect command operands in bytecode
fglock Aug 9, 2026
84ab76c
feat: reject tainted assignments to $^O
fglock Aug 9, 2026
97dbed4
feat: reject process execution with aliased %ENV
fglock Aug 9, 2026
34555c5
fix: avoid taint metadata side effects
fglock Aug 9, 2026
7edc8dd
docs: document taint mode support
fglock Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 41 additions & 35 deletions dev/design/TAINT_MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,30 @@ Perl's taint mode (`-T` flag) tracks data from external sources (environment var

## Requirements

1. **No extra storage for normal scalars** - RuntimeScalar size must not increase
2. **No extra runtime checks for normal scalars** - Only tainted scalars incur overhead
3. **Gradual implementation** - Each phase delivers working functionality
1. **Low overhead** - Taint metadata is a single boolean on `RuntimeScalar`
2. **Centralized policy** - Sources, propagation, and dangerous-operation checks use shared helpers
3. **Backend parity** - JVM and interpreter execution must preserve the same taint metadata
4. **Gradual implementation** - Each phase delivers tested functionality

## Design: TAINTED Type (Wrapper Pattern)
## Design: Scalar Taint Flag

> **Decision (2026-08-09):** The wrapper proposal below was superseded by the
> field-based implementation already present in the runtime. A wrapper adds a
> new scalar type that every value-access fast path must understand; the boolean
> flag composes with tied, read-only, special-variable, and reference scalars
> without changing their existing type.

`RuntimeScalar` owns a `boolean tainted` field. Copies and assignments preserve
the flag, operations call `propagateTaint(...)`, and external sources call
`taintFromExternalInput()`. `isTainted()` resolves special, tied, and read-only
scalars before inspecting the flag. Security-sensitive operations call the
central `RuntimeScalar.checkTaint(value, operation)` helper, which only enforces
the flag when the current thread is running under `-T`.

The original wrapper sketch is retained below as historical context, not as an
implementation target.

### Rejected Alternative: TAINTED Type (Wrapper Pattern)

Add a `TAINTED` type to RuntimeScalarType, following the existing TIED_SCALAR pattern:

Expand Down Expand Up @@ -132,6 +151,11 @@ public String toString() {

**Goal:** Add TAINTED type and basic taint detection.

**Implemented differently:** source marking and detection use the scalar taint
flag described above. `$^X`, `%ENV`, `@ARGV`, file reads, `read`, and directory
reads are tainted while `-T` is active. `Scalar::Util::tainted()` and
`builtin::is_tainted()` query `RuntimeScalar.isTainted()`.

### Changes

1. **Add TAINTED constant to RuntimeScalarType.java:**
Expand Down Expand Up @@ -190,6 +214,11 @@ public String toString() {

**Goal:** Taint propagates through assignment and operations.

**Implemented differently:** constructors and `set()` copy the boolean flag.
Concatenation, interpolation/join, substring lvalues, the primary arithmetic
operators and numeric functions, `length`, case conversion, `ord`, `oct`, and
`hex` preserve taint. Further operator coverage remains an explicit audit item.

### Changes

1. **Update set() to propagate taint:**
Expand Down Expand Up @@ -280,7 +309,7 @@ public String toString() {
```java
// Helper method
public static void checkTaint(RuntimeScalar scalar, String operation) {
if (scalar.isTainted()) {
if (GlobalContext.isTaintModeActive() && scalar.isTainted()) {
throw new PerlCompilerException(
"Insecure dependency in " + operation + " while running with -T switch"
);
Expand Down Expand Up @@ -321,6 +350,8 @@ RuntimeScalar capture = new RuntimeScalar(matchedText);
// The captured value is untainted regardless of source
```

This behavior is implemented and covered by the taint regression test.

---

## Files to Modify by Phase
Expand Down Expand Up @@ -351,37 +382,12 @@ RuntimeScalar capture = new RuntimeScalar(matchedText);

## Cleanup

After implementing the TAINTED type approach:
- Remove `RuntimeScalarTaint.java` (no longer needed)
- Remove any WeakHashMap-based taint tracking code
The rejected wrapper and WeakHashMap approaches were not introduced. There is
no `RuntimeScalarTaint.java` cleanup required.

---

## Progress Tracking

### Current Status: Phase 1 complete

### Completed Phases

- [x] **Phase 1: Minimal Fix for IPC::System::Simple** (2026-03-24)
- Modified `src/main/perl/lib/IPC/System/Simple.pm` `_check_taint()` to block ALL external commands when `${^TAINT}` is set
- Added `isTainted()` method to RuntimeScalar.java (returns false, ready for Phase 2)
- Updated `ScalarUtil.tainted()` to use `isTainted()` method
- **Bonus fix**: Reset `$?` to 0 before END blocks in SpecialBlock.java (Perl semantics) - this fixed spurious "Looks like your test exited with X" warnings from Test::Builder
- **Test results**: IPC::System::Simple 15/17 test programs pass, 169/181 subtests (93%)

### Infrastructure Complete
- [x] `-T` flag parsing
- [x] `${^TAINT}` variable
- [x] `isTainted()` method stub

### Next Steps (Phase 2)
1. Add TAINTED type constant to RuntimeScalarType.java
2. Implement `taint()` and `getActualScalar()` methods
3. Mark `$^X`, `%ENV`, `@ARGV` as tainted sources
4. Update `tainted()` to return true for TAINTED type
## Implementation Tracking

### Open Questions
- Should @ARGV be tainted? (Yes in Perl)
- Handle taint in hash/array element access?
- Taint and references - should $$ref propagate taint?
Implementation progress and test results are tracked in the commits and draft
pull request rather than maintained as a second change log in this document.
4 changes: 4 additions & 0 deletions docs/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans.

## Work in progress

- Add Perl taint mode with `-T` on both JVM and interpreter backends, including
external-input provenance, scalar and regex propagation, capture-based
untainting, and security checks for process execution, code loading, file
mutation, and other sensitive operations.
- Bugfix: targeted weak-reference sweeps preserve objects rescued by `DESTROY`
until rescue-specific reachability cleanup runs, keeping live DBIx::Class
storage callbacks valid after a schema self-rescue.
Expand Down
11 changes: 7 additions & 4 deletions docs/reference/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ PerlOnJava implements most core Perl features with some key differences:
- DBI with JDBC integration
- Subroutine prototypes
- Tied variables
- Taint mode (`-T`)
- Method Resolution Order

🚧 Partially Supported:
Expand Down Expand Up @@ -138,10 +139,9 @@ The built-in Perl debugger (`perl -d`) provides interactive debugging. See [Debu
- ✅ UTF-16 is accepted in source code.

- ✅ Accept command line switches from the shebang line.
- ✅ Accept command line switches: `-c`, `-e`, `-E`, `-p`, `-n`, `-i`, `-I`, `-0`, `-a`, `-d`, `-f`, `-F`, `-m`, `-M`, `-g`, `-l`, `-h`, `-s`, `-S`, `-x`, `-v`, `-V`, `-?`, `-w`, `-W`, `-X` are implemented.
- ✅ Accept command line switches: `-c`, `-e`, `-E`, `-p`, `-n`, `-i`, `-I`, `-0`, `-a`, `-d`, `-f`, `-F`, `-m`, `-M`, `-g`, `-l`, `-h`, `-s`, `-S`, `-T`, `-x`, `-v`, `-V`, `-?`, `-w`, `-W`, `-X` are implemented.
- ❌ Missing command line switches include:
- `-T`: Taint checks.
- `-t`: Taint checks with warnings.
- `-t`: Taint checks with warnings. The option is accepted, but warning-mode taint semantics are not implemented.
- `-u`: Dumps core after compiling.
- `-U`: Allows unsafe operations.
- `-D[number/list]`: Sets debugging flags.
Expand Down Expand Up @@ -213,7 +213,10 @@ my @copy = @{$z}; # ERROR
- ✅ **Typeglob as hash**: `*$val{$k}` for `SCALAR`, `ARRAY`, `HASH`, `CODE`, `IO` is implemented.
- ✅ **Use string as a scalar reference**: Support for scalar references from strings is implemented.
- ✅ **Tied Scalars**: Support for tying scalars to classes is implemented. See also [Tied Arrays](#arrays-hashes-and-lists), [Tied Hashes](#arrays-hashes-and-lists), [Tied Handles](#io-operations).
- ❌ **Taint checks**: Support for taint checks is not implemented.
- ✅ **Taint checks**: `-T` marks external inputs, propagates taint through
scalar and regular-expression operations, supports capture-based untainting,
and rejects tainted values at security-sensitive operations. Supported by
both JVM and interpreter backends.
- ❌ **`local` special cases**: `local *HANDLE = *HANDLE` doesn't create a new typeglob.
- 🚧 **Variable attributes**: `my $x : attr` supported via `MODIFY_SCALAR_ATTRIBUTES` etc.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,7 @@ public void visit(BlockNode node) {
// valid target for unlabeled last/next/redo (matches JVM
// EmitBlock's pushLoopLabels(... isBareBlock, isBareBlock)).
blockLoopInfo = new LoopInfo(node.labelName, blockLoopStartPc, true);
blockLoopInfo.resultReg = outerResultReg;
loopStack.push(blockLoopInfo);
}

Expand Down Expand Up @@ -6908,11 +6909,12 @@ Map<String, Integer> getCapturedVarIndices() {
* Extracted for use by CompileOperator.
*/
void handleLoopControlOperator(OperatorNode node, String op) {
boolean implicitGivenLast = node.getBooleanAnnotation("implicitGivenLast");
// Extract label if present
String labelStr = null;
boolean isDynamicLabel = false;
int dynamicLabelReg = -1;
if (node.operand instanceof ListNode labelNode && !labelNode.elements.isEmpty()) {
if (!implicitGivenLast && node.operand instanceof ListNode labelNode && !labelNode.elements.isEmpty()) {
Node arg = labelNode.elements.getFirst();
if (arg instanceof IdentifierNode) {
labelStr = ((IdentifierNode) arg).name;
Expand Down Expand Up @@ -7045,6 +7047,22 @@ void handleLoopControlOperator(OperatorNode node, String op) {
throwCompilerException("Can't \"" + op + "\" outside a loop block", node.getIndex());
}

// Preserve the final expression of a when clause as the result of its
// enclosing given block before jumping to that block's end.
if (implicitGivenLast) {
Object resultAnnotation = node.getAnnotation("implicitGivenResult");
Node result = resultAnnotation instanceof Node ? (Node) resultAnnotation : null;
if (result != null) {
compileNode(result, -1, RuntimeContextType.SCALAR);
if (targetLoop.resultReg >= 0 && lastResultReg >= 0) {
emitAliasWithTarget(targetLoop.resultReg, lastResultReg);
}
} else if (targetLoop.resultReg >= 0) {
emit(Opcodes.LOAD_UNDEF);
emitReg(targetLoop.resultReg);
}
}

// Emit the opcode and record the PC to be patched later
short opcode = op.equals("last") ? Opcodes.LAST
: op.equals("next") ? Opcodes.NEXT
Expand Down Expand Up @@ -7078,13 +7096,15 @@ private static class LoopInfo {
final boolean isTrueLoop; // True for for/while/foreach; false for do-while/bare blocks
int continuePc; // PC for next (continue block or increment)
int cleanupScopeIndex; // Lower bound for scopes bypassed by local loop control
int resultReg; // Result register for value-producing synthetic blocks

LoopInfo(String label, int startPc, boolean isTrueLoop) {
this.label = label;
this.startPc = startPc;
this.isTrueLoop = isTrueLoop;
this.continuePc = -1; // Will be set later
this.cleanupScopeIndex = -1;
this.resultReg = -1;
this.breakPcs = new ArrayList<>();
this.nextPcs = new ArrayList<>();
this.redoPcs = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,7 @@ static RuntimeScalar ensureMutableScalar(RuntimeBase val) {
}
if (val instanceof ScalarSpecialVariable sv) {
RuntimeScalar src = sv.getValueAsScalar();
RuntimeScalar copy = new RuntimeScalar();
copy.type = src.type;
copy.value = src.value;
copy.numericLiteralText = src.numericLiteralText;
copy.numericContextSeen = src.numericContextSeen;
return copy;
return new RuntimeScalar(src);
}
return (RuntimeScalar) val;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,19 @@ private static void visitSimpleUnaryWithDefault(BytecodeCompiler bc, OperatorNod
private static void visitGenericListOpCase(BytecodeCompiler bc, OperatorNode node, short opcode) {
int argsReg;
if (node.operand != null) {
bc.compileNode(node.operand, -1, RuntimeContextType.LIST);
boolean commandWithHandle = (opcode == Opcodes.SYSTEM || opcode == Opcodes.EXEC)
&& node.operand instanceof ListNode list && list.handle != null;
ListNode commandArgs = commandWithHandle ? (ListNode) node.operand : null;
if (commandWithHandle) {
commandArgs.elements.addFirst(commandArgs.handle);
}
try {
bc.compileNode(node.operand, -1, RuntimeContextType.LIST);
} finally {
if (commandWithHandle) {
commandArgs.elements.removeFirst();
}
}
int operandReg = bc.lastResultReg;
argsReg = bc.allocateRegister();
bc.emit(Opcodes.SCALAR_TO_LIST);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,9 @@ public static RuntimeList evalStringList(RuntimeScalar codeScalar,
int siteStrictOptions,
int siteFeatureFlags,
boolean isEvalbytes) {
try {
RuntimeCode.rejectTaintedEval(codeScalar);
} catch (PerlCompilerException e) {
WarnDie.catchEval(e);
return new RuntimeList(new RuntimeScalar());
}
// The eval whose source is tainted cannot catch its own security error.
// Let an enclosing eval block handle it, matching the JVM backend and Perl.
RuntimeCode.rejectTaintedEval(codeScalar);
return evalStringList(codeScalar.toString(), codeScalar.type, currentCode, registers,
sourceName, sourceLine, callContext, siteRegistry, siteStrictOptions, siteFeatureFlags, isEvalbytes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,7 @@ static RuntimeScalar ensureMutableScalar(RuntimeBase val) {
}
if (val instanceof ScalarSpecialVariable sv) {
RuntimeScalar src = sv.getValueAsScalar();
RuntimeScalar copy = new RuntimeScalar();
copy.type = src.type;
copy.value = src.value;
copy.numericLiteralText = src.numericLiteralText;
copy.numericContextSeen = src.numericContextSeen;
return copy;
return new RuntimeScalar(src);
}
return (RuntimeScalar) val;
}
Expand Down Expand Up @@ -541,7 +536,7 @@ public static int executeArrayGet(int[] bytecode, int pc, RuntimeBase[] register
RuntimeScalar idx = (RuntimeScalar) registers[indexReg];

if (arrayBase instanceof RuntimeArray arr) {
registers[rd] = arr.get(idx.getInt());
registers[rd] = arr.get(idx);
} else if (arrayBase instanceof RuntimeList list) {
int index = idx.getInt();
if (index < 0) index = list.elements.size() + index;
Expand Down Expand Up @@ -570,7 +565,7 @@ public static int executeArraySet(int[] bytecode, int pc, RuntimeBase[] register
RuntimeBase valueBase = registers[valueReg];
RuntimeScalar val = (valueBase instanceof RuntimeScalar)
? (RuntimeScalar) valueBase : valueBase.scalar();
RuntimeScalar element = arr.get(idx.getInt());
RuntimeScalar element = arr.get(idx);
element.set(val);
registers[rd] = element;
return pc;
Expand Down Expand Up @@ -741,10 +736,16 @@ public static int executeHashSet(int[] bytecode, int pc, RuntimeBase[] registers
return pc;
}

RuntimeScalar copy = new RuntimeScalar();
val.addToScalar(copy);
hash.put(key.toString(), copy);
registers[rd] = copy;
if (hash.type == RuntimeHash.TIED_HASH) {
RuntimeScalar element = hash.get(key);
element.set(val);
registers[rd] = element;
} else {
RuntimeScalar copy = new RuntimeScalar();
val.addToScalar(copy);
hash.put(key.toString(), copy);
registers[rd] = copy;
}
return pc;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1285,9 +1285,7 @@ public static int executeLength(
RuntimeBase stringBase = registers[stringReg];
RuntimeScalar stringScalar = stringBase.scalar();

String str = stringScalar.toString();
int length = str.codePointCount(0, str.length());
registers[rd] = new RuntimeScalar(length);
registers[rd] = StringOperators.length(stringScalar);

return pc;
}
Expand Down
20 changes: 18 additions & 2 deletions src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ private static void emitGotoArgsArray(EmitterVisitor emitterVisitor, Node argsNo
* @param node The operator node representing the control flow statement
* @throws PerlCompilerException if the operator is used outside a loop block
*/
static void handleNextOperator(EmitterContext ctx, OperatorNode node) {
static void handleNextOperator(EmitterVisitor emitterVisitor, OperatorNode node) {
EmitterContext ctx = emitterVisitor.ctx;
if (CompilerOptions.DEBUG_ENABLED) ctx.logDebug("visit(next)");

String operator = node.operator;
Expand Down Expand Up @@ -199,9 +200,24 @@ static void handleNextOperator(EmitterContext ctx, OperatorNode node) {
return;
}

// A when-clause's implicit last carries the clause's final value out
// of the synthetic given loop. Evaluate it explicitly in scalar
// context and leave it on the operand stack for the given block's
// result. Ordinary last remains valueless and follows the path below.
boolean implicitGivenLast = node.getBooleanAnnotation("implicitGivenLast");
if (implicitGivenLast) {
Object resultAnnotation = node.getAnnotation("implicitGivenResult");
Node result = resultAnnotation instanceof Node ? (Node) resultAnnotation : null;
if (result != null) {
result.accept(emitterVisitor.with(RuntimeContextType.SCALAR));
} else {
EmitOperator.emitUndef(ctx.mv);
}
}

// Handle return values based on context
if (loopLabels.context != RuntimeContextType.VOID) {
if (operator.equals("next") || operator.equals("last")) {
if ((operator.equals("next") || operator.equals("last")) && !implicitGivenLast) {
// For non-void contexts, ensure an 'undef' value is pushed to maintain stack consistency
EmitOperator.emitUndef(ctx.mv);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public static void emitOperatorNode(EmitterVisitor emitterVisitor, OperatorNode
case "our", "state", "my" -> EmitVariable.handleMyOperator(emitterVisitor, node);

// Control flow
case "next", "redo", "last" -> EmitControlFlow.handleNextOperator(emitterVisitor.ctx, node);
case "next", "redo", "last" -> EmitControlFlow.handleNextOperator(emitterVisitor, node);
case "return" -> EmitControlFlow.handleReturnOperator(emitterVisitor, node);
case "goto" -> EmitControlFlow.handleGotoLabel(emitterVisitor, node);

Expand Down
Loading
Loading