From e9e232eb0a5f95ba0e6f1cb11307c82a81270db2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 17:34:54 +0200 Subject: [PATCH 01/26] wip: implement core taint tracking Track tainted external inputs, propagate taint through core scalar operations, and reject tainted values at security-sensitive runtime operations. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/TAINT_MODE.md | 104 +++++++++++++++--- .../backend/bytecode/EvalStringHandler.java | 9 +- .../backend/bytecode/SlowOpcodeHandler.java | 4 +- .../perlonjava/runtime/nativ/NativeUtils.java | 4 + .../runtime/operators/ChownOperator.java | 3 +- .../runtime/operators/Directory.java | 7 ++ .../runtime/operators/IOOperator.java | 3 + .../runtime/operators/KillOperator.java | 2 + .../runtime/operators/MathOperators.java | 88 +++++++++++++++ .../runtime/operators/ModuleOperators.java | 1 + .../runtime/operators/Operator.java | 9 +- .../runtime/operators/ScalarOperators.java | 16 +++ .../runtime/operators/StringOperators.java | 66 +++++++++++ .../runtime/operators/SystemOperator.java | 44 ++++++++ .../runtime/operators/UnlinkOperator.java | 1 + .../runtime/runtimetypes/GlobalContext.java | 16 ++- .../runtime/runtimetypes/RuntimeScalar.java | 28 +++++ src/test/resources/unit/taint_mode.t | 75 +++++++++++++ 18 files changed, 448 insertions(+), 32 deletions(-) create mode 100644 src/test/resources/unit/taint_mode.t diff --git a/dev/design/TAINT_MODE.md b/dev/design/TAINT_MODE.md index 6593e7d9ac..3a54391cf4 100644 --- a/dev/design/TAINT_MODE.md +++ b/dev/design/TAINT_MODE.md @@ -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: @@ -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:** @@ -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:** @@ -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" ); @@ -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 @@ -351,15 +382,14 @@ 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 +### Current Status: Core taint mode implemented; extended Perl-core parity in progress ### Completed Phases @@ -370,18 +400,56 @@ After implementing the TAINTED type approach: - **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%) +- [x] **Phase 2: Taint sources and detection** (2026-08-09) + - Uses the existing `RuntimeScalar.tainted` field rather than a wrapper type + - Marks `$^X`, `%ENV`, `@ARGV`, readline/read, and directory input + - Supports `Scalar::Util::tainted()` and `builtin::is_tainted()` + - Files: `RuntimeScalar.java`, `GlobalContext.java`, existing IO source helpers + +- [x] **Phase 3: Core propagation** (2026-08-09) + - Preserves taint through scalar copy/assignment, concatenation, substring, + primary arithmetic, numeric functions, length, case conversion, and scalar + numeric conversions + - Fixed interpreter `length` parity by routing through `StringOperators.length()` + - Files: `RuntimeScalar.java`, `MathOperators.java`, `StringOperators.java`, + `ScalarOperators.java`, `SlowOpcodeHandler.java` + +- [x] **Phase 4: Dangerous operation enforcement** (2026-08-09) + - Rejects tainted process commands/arguments for `system`, `exec`, qx/backticks, + and pipe opens; checks dangerous process environment variables + - Rejects tainted paths for `unlink`, `mkdir`, `rmdir`, `chdir`, `rename`, + `link`, `symlink`, `chmod`, `chown`, `require`, and `do` + - Rejects tainted signal/PID arguments to `kill` + - Fixed interpreter eval behavior so a tainted eval cannot catch its own error + +- [x] **Phase 5: Regex capture untainting** (2026-08-09) + - Validated captures are clean scalars, matching Perl's standard untaint idiom + +- [x] **Cross-backend regression coverage** (2026-08-09) + - Added `src/test/resources/unit/taint_mode.t` (39 tests) + - Validated with system Perl, JVM backend, interpreter backend, and full `make` + ### Infrastructure Complete - [x] `-T` flag parsing - [x] `${^TAINT}` variable -- [x] `isTainted()` method stub +- [x] Scalar taint metadata and `isTainted()` resolution +- [x] Central propagation and enforcement helpers +- [x] Thread-local taint mode for nested/concurrent execution -### 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 +### Next Steps + +1. Audit the remaining string, bitwise, comparison, list, and formatting + operators against `perl5_t/t/op/taint.t` and add propagation where Perl does. +2. Implement Perl's secure-`PATH` directory checks and nuanced `%ENV{TERM}` rules. +3. Expand external-source coverage for platform/network/database APIs as their + Perl-core taint cases become runnable. +4. Continue raising `perl5_t/t/op/taint.t` from the current early-stop baseline; + it presently reaches 23/1065 before unsupported environment/path semantics + stop the file. ### Open Questions -- Should @ARGV be tainted? (Yes in Perl) -- Handle taint in hash/array element access? -- Taint and references - should $$ref propagate taint? + +- Which reference/container operations should propagate value taint versus + preserve only the contained scalar's taint? +- Should the remaining operator audit be completed in one compatibility PR or + split by operator family to keep performance review tractable? diff --git a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java index 70049fb293..a3a9955412 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java @@ -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); } diff --git a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java index 0e300b011e..dc4a78ba99 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/SlowOpcodeHandler.java @@ -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; } diff --git a/src/main/java/org/perlonjava/runtime/nativ/NativeUtils.java b/src/main/java/org/perlonjava/runtime/nativ/NativeUtils.java index a4f3f4fc77..e333e37dd9 100644 --- a/src/main/java/org/perlonjava/runtime/nativ/NativeUtils.java +++ b/src/main/java/org/perlonjava/runtime/nativ/NativeUtils.java @@ -25,6 +25,8 @@ public static RuntimeScalar symlink(int ctx, RuntimeBase... args) { return new RuntimeScalar(0); } + RuntimeScalar.checkTaint(args[0].scalar(), "symlink"); + RuntimeScalar.checkTaint(args[1].scalar(), "symlink"); String oldFile = RuntimeIO.sanitizePathname("symlink", args[0].toString()); Path link = RuntimeIO.resolvePath(args[1].toString(), "symlink"); @@ -72,6 +74,8 @@ public static RuntimeScalar link(int ctx, RuntimeBase... args) { return new RuntimeScalar(0); } + RuntimeScalar.checkTaint(args[0].scalar(), "link"); + RuntimeScalar.checkTaint(args[1].scalar(), "link"); String oldFile = RuntimeIO.resolvePath(args[0].toString()).toString(); String newFile = RuntimeIO.resolvePath(args[1].toString()).toString(); diff --git a/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java b/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java index 2c0bc530c2..7bca0e92d7 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java @@ -47,6 +47,7 @@ public static RuntimeScalar chown(int ctx, RuntimeBase... args) { // Handle both scalar filenames and lists of filenames for (RuntimeScalar fileArg : arg) { + RuntimeScalar.checkTaint(fileArg, "chown"); boolean result = false; try { // Check if this is a filehandle (glob reference) @@ -262,4 +263,4 @@ public static boolean isChownRestricted() { // This is a safe default assumption return true; } -} \ No newline at end of file +} diff --git a/src/main/java/org/perlonjava/runtime/operators/Directory.java b/src/main/java/org/perlonjava/runtime/operators/Directory.java index 92d8289665..fe94c4e3df 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Directory.java +++ b/src/main/java/org/perlonjava/runtime/operators/Directory.java @@ -22,6 +22,7 @@ public class Directory { public static RuntimeScalar chdir(RuntimeScalar runtimeScalar) { + RuntimeScalar.checkTaint(runtimeScalar, "chdir"); // chdir EXPR // chdir FILEHANDLE // chdir DIRHANDLE @@ -109,6 +110,7 @@ public static RuntimeScalar chdir(RuntimeScalar runtimeScalar) { } public static RuntimeScalar rmdir(RuntimeScalar runtimeScalar) { + RuntimeScalar.checkTaint(runtimeScalar, "rmdir"); String dirName = runtimeScalar.toString(); try { @@ -238,6 +240,11 @@ public static RuntimeScalar seekdir(RuntimeList args) { } public static RuntimeScalar mkdir(RuntimeList args) { + if (!args.elements.isEmpty()) { + RuntimeScalar.checkTaint(args.elements.getFirst().scalar(), "mkdir"); + } else { + RuntimeScalar.checkTaint(getGlobalVariable("main::_"), "mkdir"); + } String fileName; int mode; diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index a5918df289..5bf4152983 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -693,6 +693,9 @@ public static RuntimeScalar open(int ctx, RuntimeBase... args) { RuntimeIO fh; if (mode.contains("|")) { + for (int i = 1; i < args.length; i++) { + RuntimeScalar.checkTaint(args[i].scalar(), "open"); + } // Check for fork-open pattern: open FH, "-|" or open FH, "|-" with no command // This is the 2-arg piped open that normally forks in Perl if (args.length == 2 && (mode.equals("-|") || mode.equals("|-"))) { diff --git a/src/main/java/org/perlonjava/runtime/operators/KillOperator.java b/src/main/java/org/perlonjava/runtime/operators/KillOperator.java index 10ae32f481..b62e64c50b 100644 --- a/src/main/java/org/perlonjava/runtime/operators/KillOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/KillOperator.java @@ -31,6 +31,7 @@ public static RuntimeScalar kill(int ctx, RuntimeBase... args) { // First argument is the signal RuntimeScalar signalArg = args[0].getFirst(); + RuntimeScalar.checkTaint(signalArg, "kill"); int signal; // Handle named signals (e.g., "TERM", "KILL", "HUP") @@ -52,6 +53,7 @@ public static RuntimeScalar kill(int ctx, RuntimeBase... args) { // Process each PID starting from second argument for (int i = 1; i < args.length; i++) { for (RuntimeScalar scalar : args[i]) { + RuntimeScalar.checkTaint(scalar, "kill"); int pid = scalar.getInt(); // Special case: negative PID means process group diff --git a/src/main/java/org/perlonjava/runtime/operators/MathOperators.java b/src/main/java/org/perlonjava/runtime/operators/MathOperators.java index 9455434b1a..3e6795e879 100644 --- a/src/main/java/org/perlonjava/runtime/operators/MathOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/MathOperators.java @@ -233,6 +233,10 @@ public static RuntimeScalar addWarn(RuntimeScalar arg1, int arg2) { * @return A new RuntimeScalar representing the sum. */ public static RuntimeScalar add(RuntimeScalar arg1, RuntimeScalar arg2) { + return addUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar addUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -279,6 +283,10 @@ public static RuntimeScalar add(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the sum. */ public static RuntimeScalar addWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return addWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar addWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -389,6 +397,10 @@ public static RuntimeScalar subtractWarn(RuntimeScalar arg1, int arg2) { * @return A new RuntimeScalar representing the difference. */ public static RuntimeScalar subtract(RuntimeScalar arg1, RuntimeScalar arg2) { + return subtractUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar subtractUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -435,6 +447,10 @@ public static RuntimeScalar subtract(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the difference. */ public static RuntimeScalar subtractWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return subtractWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar subtractWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -482,6 +498,10 @@ public static RuntimeScalar subtractWarn(RuntimeScalar arg1, RuntimeScalar arg2) * @return A new RuntimeScalar representing the product. */ public static RuntimeScalar multiply(RuntimeScalar arg1, RuntimeScalar arg2) { + return multiplyUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar multiplyUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -529,6 +549,10 @@ public static RuntimeScalar multiply(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the product. */ public static RuntimeScalar multiplyWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return multiplyWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar multiplyWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Fast path: both INTEGER - skip blessedId check, getNumber(), type checks if (arg1.type == INTEGER && arg2.type == INTEGER) { int a = (int) arg1.value; @@ -577,6 +601,10 @@ public static RuntimeScalar multiplyWarn(RuntimeScalar arg1, RuntimeScalar arg2) * @throws PerlCompilerException if division by zero occurs. */ public static RuntimeScalar divide(RuntimeScalar arg1, RuntimeScalar arg2) { + return divideUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar divideUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -614,6 +642,10 @@ public static RuntimeScalar divide(RuntimeScalar arg1, RuntimeScalar arg2) { * @throws PerlCompilerException if division by zero occurs. */ public static RuntimeScalar divideWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return divideWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar divideWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -650,6 +682,10 @@ public static RuntimeScalar divideWarn(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the modulus. */ public static RuntimeScalar modulus(RuntimeScalar arg1, RuntimeScalar arg2) { + return modulusUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar modulusUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -689,6 +725,10 @@ public static RuntimeScalar modulus(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the modulus. */ public static RuntimeScalar modulusWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return modulusWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar modulusWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -1105,6 +1145,10 @@ private static double truncate(double value) { * @return A new RuntimeScalar representing the natural logarithm. */ public static RuntimeScalar log(RuntimeScalar runtimeScalar) { + return logUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar logUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1126,6 +1170,10 @@ public static RuntimeScalar log(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar representing the square root. */ public static RuntimeScalar sqrt(RuntimeScalar runtimeScalar) { + return sqrtUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar sqrtUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1147,6 +1195,10 @@ public static RuntimeScalar sqrt(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar representing the cosine. */ public static RuntimeScalar cos(RuntimeScalar runtimeScalar) { + return cosUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar cosUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1164,6 +1216,10 @@ public static RuntimeScalar cos(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar representing the sine. */ public static RuntimeScalar sin(RuntimeScalar runtimeScalar) { + return sinUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar sinUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1181,6 +1237,10 @@ public static RuntimeScalar sin(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar representing the exponential. */ public static RuntimeScalar exp(RuntimeScalar runtimeScalar) { + return expUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar expUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1200,6 +1260,10 @@ public static RuntimeScalar exp(RuntimeScalar runtimeScalar) { * @return A new RuntimeScalar representing the power. */ public static RuntimeScalar pow(RuntimeScalar arg1, RuntimeScalar arg2) { + return powUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar powUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -1220,6 +1284,10 @@ public static RuntimeScalar pow(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the power. */ public static RuntimeScalar powWarn(RuntimeScalar arg1, RuntimeScalar arg2) { + return powWarnUnpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar powWarnUnpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -1246,6 +1314,10 @@ public static RuntimeScalar powWarn(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the angle theta in radians. */ public static RuntimeScalar atan2(RuntimeScalar arg1, RuntimeScalar arg2) { + return atan2Unpropagated(arg1, arg2).propagateTaint(arg1, arg2); + } + + private static RuntimeScalar atan2Unpropagated(RuntimeScalar arg1, RuntimeScalar arg2) { // Prepare overload context and check if object is eligible for overloading int blessId = blessedId(arg1); int blessId2 = blessedId(arg2); @@ -1264,6 +1336,10 @@ public static RuntimeScalar atan2(RuntimeScalar arg1, RuntimeScalar arg2) { * @return A new RuntimeScalar representing the absolute value. */ public static RuntimeScalar abs(RuntimeScalar runtimeScalar) { + return absUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar absUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1292,6 +1368,10 @@ public static RuntimeScalar abs(RuntimeScalar runtimeScalar) { * Fast path - no warning checks. */ public static RuntimeScalar unaryMinus(RuntimeScalar runtimeScalar) { + return unaryMinusUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar unaryMinusUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1309,6 +1389,10 @@ public static RuntimeScalar unaryMinus(RuntimeScalar runtimeScalar) { * Called when 'use warnings "uninitialized"' is in effect. */ public static RuntimeScalar unaryMinusWarn(RuntimeScalar runtimeScalar) { + return unaryMinusWarnUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar unaryMinusWarnUnpropagated(RuntimeScalar runtimeScalar) { // Check if object is eligible for overloading int blessId = blessedId(runtimeScalar); if (blessId < 0) { @@ -1346,6 +1430,10 @@ public static RuntimeScalar unaryMinusWarn(RuntimeScalar runtimeScalar) { } public static RuntimeScalar integer(RuntimeScalar arg1) { + return integerUnpropagated(arg1).propagateTaint(arg1); + } + + private static RuntimeScalar integerUnpropagated(RuntimeScalar arg1) { // Check if object is eligible for overloading int blessId = blessedId(arg1); if (blessId < 0) { diff --git a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java index 6bf5f6a7b0..3ab769a289 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ModuleOperators.java @@ -142,6 +142,7 @@ public static RuntimeScalar requireInPackage(RuntimeScalar runtimeScalar, String * @return Result of execution (undef on error, with $@ or $! set) */ private static RuntimeBase doFile(RuntimeScalar runtimeScalar, boolean setINC, boolean isRequire, int ctx) { + RuntimeScalar.checkTaint(runtimeScalar, isRequire ? "require" : "do"); // Clear error variables at start GlobalVariable.setGlobalVariable("main::@", ""); GlobalVariable.setGlobalVariable("main::!", ""); diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index f109e5a3e1..b301903a0c 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -54,6 +54,7 @@ public static RuntimeScalar chmod(RuntimeList runtimeList) { // Process each file in the flattened list for (RuntimeScalar fileScalar : fileList) { + RuntimeScalar.checkTaint(fileScalar, "chmod"); String fileName = fileScalar.toString(); Path resolved = RuntimeIO.resolvePath(fileName, "chmod"); if (resolved == null) { @@ -972,8 +973,12 @@ public static RuntimeScalar rename(int ctx, RuntimeBase... args) { throw new PerlCompilerException("Not enough arguments for rename"); } - String oldName = args[0].getFirst().toString(); - String newName = args[1].getFirst().toString(); + RuntimeScalar oldNameScalar = args[0].getFirst(); + RuntimeScalar newNameScalar = args[1].getFirst(); + RuntimeScalar.checkTaint(oldNameScalar, "rename"); + RuntimeScalar.checkTaint(newNameScalar, "rename"); + String oldName = oldNameScalar.toString(); + String newName = newNameScalar.toString(); try { Path oldPath = RuntimeIO.resolvePath(oldName); diff --git a/src/main/java/org/perlonjava/runtime/operators/ScalarOperators.java b/src/main/java/org/perlonjava/runtime/operators/ScalarOperators.java index 64fab6051e..aa2db017f2 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ScalarOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/ScalarOperators.java @@ -11,6 +11,10 @@ public class ScalarOperators { public static RuntimeScalar oct(RuntimeScalar runtimeScalar) { + return octUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar octUnpropagated(RuntimeScalar runtimeScalar) { String expr = runtimeScalar.toString(); StringParser.assertNoWideCharacters(expr, "oct"); @@ -109,6 +113,10 @@ public static RuntimeScalar oct(RuntimeScalar runtimeScalar) { } public static RuntimeScalar ord(RuntimeScalar runtimeScalar) { + return ordUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ordUnpropagated(RuntimeScalar runtimeScalar) { String str = runtimeScalar.toString(); long i; if (str.isEmpty()) { @@ -127,6 +135,10 @@ public static RuntimeScalar ord(RuntimeScalar runtimeScalar) { * @return a RuntimeScalar containing the byte value (0-255) */ public static RuntimeScalar ordBytes(RuntimeScalar runtimeScalar) { + return ordBytesUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ordBytesUnpropagated(RuntimeScalar runtimeScalar) { // Regex capture variables are read-only proxies. Resolve the current // capture so its BYTE_STRING flag participates in byte-wise ord(). if (runtimeScalar instanceof ScalarSpecialVariable specialVariable) { @@ -159,6 +171,10 @@ public static RuntimeScalar ordBytes(RuntimeScalar runtimeScalar) { } public static RuntimeScalar hex(RuntimeScalar runtimeScalar) { + return hexUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar hexUnpropagated(RuntimeScalar runtimeScalar) { String expr = runtimeScalar.toString(); long result = 0; boolean useDouble = false; diff --git a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java index 1a593e39cd..d17c7c8ac4 100644 --- a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java @@ -31,6 +31,10 @@ private static boolean bytesHintActive() { * @return a {@link RuntimeScalar} containing the length of the input as an integer */ public static RuntimeScalar length(RuntimeScalar runtimeScalar) { + return lengthUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lengthUnpropagated(RuntimeScalar runtimeScalar) { // If the scalar is undefined, return undef if (!runtimeScalar.getDefinedBoolean()) { return RuntimeScalarCache.scalarUndef; @@ -48,6 +52,10 @@ public static RuntimeScalar length(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} containing the byte length of the input */ public static RuntimeScalar lengthBytes(RuntimeScalar runtimeScalar) { + return lengthBytesUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lengthBytesUnpropagated(RuntimeScalar runtimeScalar) { // If the scalar is undefined, return undef if (!runtimeScalar.getDefinedBoolean()) { return RuntimeScalarCache.scalarUndef; @@ -174,6 +182,10 @@ private static boolean perlQuotemetaMustQuote(int cp, RuntimeScalar runtimeScala * @return a {@link RuntimeScalar} with the case-folded string */ public static RuntimeScalar fc(RuntimeScalar runtimeScalar) { + return fcUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar fcUnpropagated(RuntimeScalar runtimeScalar) { if (runtimeScalar.type == RuntimeScalarType.BYTE_STRING) { return caseFoldBytesAsciiOnly(runtimeScalar); } @@ -185,6 +197,10 @@ public static RuntimeScalar fc(RuntimeScalar runtimeScalar) { * This is used under the unicode_strings feature. */ public static RuntimeScalar fcUnicode(RuntimeScalar runtimeScalar) { + return fcUnicodeUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar fcUnicodeUnpropagated(RuntimeScalar runtimeScalar) { String str = runtimeScalar.toString(); // Perform full Unicode case folding using ICU4J CaseMap // Note: We do NOT use NFKC normalization because Perl's fc() preserves @@ -203,6 +219,10 @@ public static RuntimeScalar fcUnicode(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} with the case-folded bytes */ public static RuntimeScalar fcBytes(RuntimeScalar runtimeScalar) { + return fcBytesUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar fcBytesUnpropagated(RuntimeScalar runtimeScalar) { // Under 'use bytes', we operate on the UTF-8 bytes of the input RuntimeScalar asBytes = toUtf8Bytes(runtimeScalar); // Case-fold only ASCII bytes (A-Z -> a-z), leave others unchanged @@ -217,6 +237,10 @@ public static RuntimeScalar fcBytes(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} with the lowercase string */ public static RuntimeScalar lc(RuntimeScalar runtimeScalar) { + return lcUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lcUnpropagated(RuntimeScalar runtimeScalar) { if (runtimeScalar.type == RuntimeScalarType.BYTE_STRING) { return caseFoldBytesAsciiOnly(runtimeScalar); } @@ -228,6 +252,10 @@ public static RuntimeScalar lc(RuntimeScalar runtimeScalar) { * This is used under the unicode_strings feature. */ public static RuntimeScalar lcUnicode(RuntimeScalar runtimeScalar) { + return lcUnicodeUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lcUnicodeUnpropagated(RuntimeScalar runtimeScalar) { // Convert the string to lowercase using ICU4J for proper Unicode handling String str = UCharacter.toLowerCase(runtimeScalar.toString()); return makeStringResult(str, runtimeScalar); @@ -238,6 +266,10 @@ public static RuntimeScalar lcUnicode(RuntimeScalar runtimeScalar) { * Operates on the UTF-8 bytes of the input, only affecting ASCII. */ public static RuntimeScalar lcBytes(RuntimeScalar runtimeScalar) { + return lcBytesUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lcBytesUnpropagated(RuntimeScalar runtimeScalar) { RuntimeScalar asBytes = toUtf8Bytes(runtimeScalar); return caseFoldBytesAsciiOnly(asBytes); } @@ -250,6 +282,10 @@ public static RuntimeScalar lcBytes(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} with the first character in lowercase */ public static RuntimeScalar lcfirst(RuntimeScalar runtimeScalar) { + return lcfirstUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lcfirstUnpropagated(RuntimeScalar runtimeScalar) { if (runtimeScalar.type == RuntimeScalarType.BYTE_STRING) { return lcfirstBytes(runtimeScalar); } @@ -261,6 +297,10 @@ public static RuntimeScalar lcfirst(RuntimeScalar runtimeScalar) { * This is used under the unicode_strings feature. */ public static RuntimeScalar lcfirstUnicode(RuntimeScalar runtimeScalar) { + return lcfirstUnicodeUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar lcfirstUnicodeUnpropagated(RuntimeScalar runtimeScalar) { String str = runtimeScalar.toString(); // Check if the string is empty if (str.isEmpty()) { @@ -283,6 +323,10 @@ public static RuntimeScalar lcfirstUnicode(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} with the uppercase string */ public static RuntimeScalar uc(RuntimeScalar runtimeScalar) { + return ucUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ucUnpropagated(RuntimeScalar runtimeScalar) { if (runtimeScalar.type == RuntimeScalarType.BYTE_STRING) { return uppercaseBytesAsciiOnly(runtimeScalar); } @@ -294,6 +338,10 @@ public static RuntimeScalar uc(RuntimeScalar runtimeScalar) { * This is used under the unicode_strings feature. */ public static RuntimeScalar ucUnicode(RuntimeScalar runtimeScalar) { + return ucUnicodeUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ucUnicodeUnpropagated(RuntimeScalar runtimeScalar) { // Convert the string to uppercase using ICU4J for proper Unicode handling String str = UCharacter.toUpperCase(runtimeScalar.toString()); return makeStringResult(str, runtimeScalar); @@ -308,6 +356,10 @@ public static RuntimeScalar ucUnicode(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} with the first character in titlecase */ public static RuntimeScalar ucfirst(RuntimeScalar runtimeScalar) { + return ucfirstUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ucfirstUnpropagated(RuntimeScalar runtimeScalar) { if (runtimeScalar.type == RuntimeScalarType.BYTE_STRING) { return ucfirstBytes(runtimeScalar); } @@ -319,6 +371,10 @@ public static RuntimeScalar ucfirst(RuntimeScalar runtimeScalar) { * This is used under the unicode_strings feature. */ public static RuntimeScalar ucfirstUnicode(RuntimeScalar runtimeScalar) { + return ucfirstUnicodeUnpropagated(runtimeScalar).propagateTaint(runtimeScalar); + } + + private static RuntimeScalar ucfirstUnicodeUnpropagated(RuntimeScalar runtimeScalar) { String str = runtimeScalar.toString(); // Check if the string is empty if (str.isEmpty()) { @@ -351,6 +407,11 @@ public static RuntimeScalar ucfirstUnicode(RuntimeScalar runtimeScalar) { * @return a {@link RuntimeScalar} containing the index of the first occurrence, or -1 if not found */ public static RuntimeScalar index(RuntimeScalar runtimeScalar, RuntimeScalar substr, RuntimeScalar position) { + return indexUnpropagated(runtimeScalar, substr, position) + .propagateTaint(runtimeScalar, substr, position); + } + + private static RuntimeScalar indexUnpropagated(RuntimeScalar runtimeScalar, RuntimeScalar substr, RuntimeScalar position) { String str = runtimeScalar.toString(); String sub = substr.toString(); int pos = position.type == RuntimeScalarType.UNDEF @@ -392,6 +453,11 @@ public static RuntimeScalar index(RuntimeScalar runtimeScalar, RuntimeScalar sub * @return a {@link RuntimeScalar} containing the index of the last occurrence, or -1 if not found */ public static RuntimeScalar rindex(RuntimeScalar runtimeScalar, RuntimeScalar substr, RuntimeScalar position) { + return rindexUnpropagated(runtimeScalar, substr, position) + .propagateTaint(runtimeScalar, substr, position); + } + + private static RuntimeScalar rindexUnpropagated(RuntimeScalar runtimeScalar, RuntimeScalar substr, RuntimeScalar position) { String str = runtimeScalar.toString(); String sub = substr.toString(); int pos = position.type == RuntimeScalarType.UNDEF diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index e5197bf42d..690abc470d 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -124,6 +124,9 @@ public static RuntimeBase systemCommand(RuntimeScalar command, int ctx) { } } + RuntimeScalar.checkTaint(command, "``"); + checkTaintEnvironment(); + String cmd = command.toString(); CommandResult result; @@ -159,6 +162,8 @@ public static RuntimeBase systemCommand(RuntimeScalar command, int ctx) { * @throws PerlCompilerException if an error occurs during command execution. */ public static RuntimeScalar system(RuntimeList args, boolean hasHandle, int ctx) { + checkTaintArguments(args, "system"); + checkTaintEnvironment(); // Flatten the arguments - arrays and lists should be expanded to individual elements List flattenedArgs = flattenToStringList(args.elements); @@ -235,6 +240,43 @@ private static List flattenToStringList(List elements) { return result; } + private static void checkTaintArguments(RuntimeList args, String operation) { + if (!GlobalContext.isTaintModeActive()) { + return; + } + for (RuntimeBase value : args.elements) { + checkTaintValue(value, operation); + } + } + + private static void checkTaintValue(RuntimeBase value, String operation) { + if (value instanceof RuntimeScalar scalar) { + RuntimeScalar.checkTaint(scalar, operation); + } else if (value instanceof RuntimeList list) { + for (RuntimeBase element : list.elements) { + checkTaintValue(element, operation); + } + } else if (value instanceof RuntimeArray array) { + for (RuntimeScalar element : array.elements) { + RuntimeScalar.checkTaint(element, operation); + } + } + } + + private static void checkTaintEnvironment() { + if (!GlobalContext.isTaintModeActive()) { + return; + } + RuntimeHash env = GlobalVariable.getGlobalHash("main::ENV"); + for (String name : List.of("PATH", "IFS", "CDPATH", "ENV", "BASH_ENV")) { + RuntimeScalar value = env.elements.get(name); + if (value != null && value.isTainted()) { + throw new PerlCompilerException( + "Insecure $ENV{" + name + "} while running with -T switch"); + } + } + } + private static List splitDirectCommandWords(String command) { String trimmed = command.trim(); if (trimmed.isEmpty()) { @@ -905,6 +947,8 @@ private static RuntimeBase processOutput(String output, int ctx) { * @throws PerlCompilerException if an error occurs during command execution. */ public static RuntimeScalar exec(RuntimeList args, boolean hasHandle, int ctx) { + checkTaintArguments(args, "exec"); + checkTaintEnvironment(); // Flatten the arguments - arrays and lists should be expanded to individual elements List flattenedArgs = flattenToStringList(args.elements); diff --git a/src/main/java/org/perlonjava/runtime/operators/UnlinkOperator.java b/src/main/java/org/perlonjava/runtime/operators/UnlinkOperator.java index d46c9d1e73..16674335ee 100644 --- a/src/main/java/org/perlonjava/runtime/operators/UnlinkOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/UnlinkOperator.java @@ -35,6 +35,7 @@ public static RuntimeBase unlink(int ctx, RuntimeBase... args) { } for (RuntimeScalar fileScalar : fileList) { + RuntimeScalar.checkTaint(fileScalar, "unlink"); String fileName = fileScalar.toString(); if (deleteFile(fileName)) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 79ff7d8155..6287db8af8 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -76,11 +76,16 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { // Initialize $^X - the name used to execute the current copy of Perl // PERLONJAVA_EXECUTABLE is set by the `jperl` or `jperl.bat` launcher String perlExecutable = System.getenv("PERLONJAVA_EXECUTABLE"); + RuntimeScalar executableVariable = + GlobalVariable.getGlobalVariable("main::" + Character.toString('X' - 'A' + 1)); if (perlExecutable != null && !perlExecutable.isEmpty()) { - GlobalVariable.getGlobalVariable("main::" + Character.toString('X' - 'A' + 1)).set(perlExecutable); + executableVariable.set(perlExecutable); } else { // Fallback to "jperl" if environment variable is not set - GlobalVariable.getGlobalVariable("main::" + Character.toString('X' - 'A' + 1)).set("jperl"); + executableVariable.set("jperl"); + } + if (compilerOptions.taintMode) { + executableVariable.tainted = true; } GlobalVariable.getGlobalVariable("main::]").set(Configuration.getPerlVersionOld()); // initialize $] to Perl version @@ -211,6 +216,13 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { env.put(k, envValue); }); + // Command-line arguments are external input just like %ENV and file data. + if (compilerOptions.taintMode) { + for (RuntimeScalar argument : compilerOptions.argumentList.elements) { + argument.tainted = true; + } + } + /* Initialize @INC. @INC Search order mirrors Perl 5's site_perl > core pattern: - "-I" argument (highest priority, user override) diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 593a4edd1a..9d3a46534d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -1085,6 +1085,34 @@ public RuntimeScalar taintFromExternalInput() { return this; } + /** Mark this scalar tainted when any scalar input to an operation is tainted. */ + public RuntimeScalar propagateTaint(RuntimeScalar... inputs) { + for (RuntimeScalar input : inputs) { + if (input != null && input.isTainted()) { + if (isTainted()) { + return this; + } + // Operator helpers sometimes return a shared scalar-cache value. + // Never attach taint metadata to that shared instance. + RuntimeScalar taintedResult = new RuntimeScalar(this); + taintedResult.tainted = true; + return taintedResult; + } + } + return this; + } + + /** + * Reject a tainted value at a security-sensitive operation. Taint is only + * meaningful while the current Perl program is running with {@code -T}. + */ + public static void checkTaint(RuntimeScalar scalar, String operation) { + if (GlobalContext.isTaintModeActive() && scalar != null && scalar.isTainted()) { + throw new PerlCompilerException( + "Insecure dependency in " + operation + " while running with -T switch"); + } + } + // Add itself to a RuntimeArray. // // ─── WARNING: refCount accounting is intentionally asymmetric here ─── diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t new file mode 100644 index 0000000000..8b22ce028f --- /dev/null +++ b/src/test/resources/unit/taint_mode.t @@ -0,0 +1,75 @@ +#!perl -T +use strict; +use warnings; +use Scalar::Util qw(tainted); +use Test::More; + +my $empty_taint = substr($^X, 0, 0); +my $text = "abc$empty_taint"; + +ok(${^TAINT}, '-T enables ${^TAINT}'); +ok(tainted($^X), '$^X is tainted'); +ok(tainted($ENV{PATH}), '%ENV values are tainted'); +ok(tainted($empty_taint), 'substr preserves source taint'); +ok(tainted($text), 'concatenation propagates taint'); + +my $copy = $text; +ok(tainted($copy), 'assignment propagates taint'); +{ + no warnings 'numeric'; + ok(tainted(1 + $empty_taint), 'addition propagates taint'); + ok(tainted(2 * (1 + $empty_taint)), 'multiplication propagates taint'); +} +ok(tainted(length($text)), 'length propagates taint'); +ok(!tainted(length('abc')), 'taint propagation does not contaminate cached clean scalars'); +ok(tainted(uc($text)), 'case conversion propagates taint'); +ok(tainted(ord($text)), 'ord propagates taint'); + +$text =~ /^(.*)$/; +ok(!tainted($1), 'regex capture untaints validated input'); + +my $eval_ok = eval { eval $text; 1 }; +ok(!$eval_ok, 'tainted eval STRING is rejected'); +like($@, qr/^Insecure dependency in eval while running with -T switch/, + 'tainted eval reports the Perl security error'); + +{ + local @ENV{qw(PATH IFS CDPATH ENV BASH_ENV)}; + $ENV{PATH} = '/usr/bin'; + delete @ENV{qw(IFS CDPATH ENV BASH_ENV)}; + + my $system_ok = eval { system("/bin/echo$empty_taint", 'unused'); 1 }; + ok(!$system_ok, 'system rejects a tainted command'); + like($@, qr/^Insecure dependency in system while running with -T switch/, + 'system reports the Perl security error'); + + my $qx_ok = eval { qx{/bin/echo$empty_taint unused}; 1 }; + ok(!$qx_ok, 'qx rejects a tainted command'); + like($@, qr/^Insecure dependency in `` while running with -T switch/, + 'qx reports the Perl security error'); +} + +my $tainted_path = "/tmp/perlonjava-taint-no-such-$$" . $empty_taint; +my @file_operations = ( + [ unlink => sub { unlink $tainted_path } ], + [ mkdir => sub { mkdir $tainted_path } ], + [ rmdir => sub { rmdir $tainted_path } ], + [ chdir => sub { chdir $tainted_path } ], + [ rename => sub { rename $tainted_path, "$tainted_path-new" } ], + [ link => sub { link $tainted_path, "$tainted_path-link" } ], + [ symlink => sub { symlink $tainted_path, "$tainted_path-symlink" } ], + [ chmod => sub { chmod 0600, $tainted_path } ], + [ require => sub { require $tainted_path } ], + [ do => sub { do $tainted_path } ], +); + +for my $case (@file_operations) { + my ($operation, $code) = @$case; + my $ok = eval { $code->(); 1 }; + ok(!$ok, "$operation rejects a tainted path"); + like($@, + qr/^Insecure dependency in $operation while running with -T switch/, + "$operation reports the Perl security error"); +} + +done_testing; From 2cd756956ee1cd4ced5bbf6de1b8e5534232cc12 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 17:42:06 +0200 Subject: [PATCH 02/26] feat: support Perl taint probes Track join taint for Perl's legacy join-and-kill taint detection idiom and clear the probe state after use. Remove the duplicate progress ledger from the design document so the draft PR commit history remains the implementation log. This advances perl5_t/t/op/taint.t from 23 to 858 tests reached. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- dev/design/TAINT_MODE.md | 70 ++----------------- .../runtime/operators/KillOperator.java | 7 ++ .../runtime/operators/StringOperators.java | 11 ++- .../runtime/runtimetypes/GlobalContext.java | 14 ++++ src/test/resources/unit/taint_mode.t | 7 ++ 5 files changed, 40 insertions(+), 69 deletions(-) diff --git a/dev/design/TAINT_MODE.md b/dev/design/TAINT_MODE.md index 3a54391cf4..59023dd1f7 100644 --- a/dev/design/TAINT_MODE.md +++ b/dev/design/TAINT_MODE.md @@ -387,69 +387,7 @@ no `RuntimeScalarTaint.java` cleanup required. --- -## Progress Tracking - -### Current Status: Core taint mode implemented; extended Perl-core parity in progress - -### 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%) - -- [x] **Phase 2: Taint sources and detection** (2026-08-09) - - Uses the existing `RuntimeScalar.tainted` field rather than a wrapper type - - Marks `$^X`, `%ENV`, `@ARGV`, readline/read, and directory input - - Supports `Scalar::Util::tainted()` and `builtin::is_tainted()` - - Files: `RuntimeScalar.java`, `GlobalContext.java`, existing IO source helpers - -- [x] **Phase 3: Core propagation** (2026-08-09) - - Preserves taint through scalar copy/assignment, concatenation, substring, - primary arithmetic, numeric functions, length, case conversion, and scalar - numeric conversions - - Fixed interpreter `length` parity by routing through `StringOperators.length()` - - Files: `RuntimeScalar.java`, `MathOperators.java`, `StringOperators.java`, - `ScalarOperators.java`, `SlowOpcodeHandler.java` - -- [x] **Phase 4: Dangerous operation enforcement** (2026-08-09) - - Rejects tainted process commands/arguments for `system`, `exec`, qx/backticks, - and pipe opens; checks dangerous process environment variables - - Rejects tainted paths for `unlink`, `mkdir`, `rmdir`, `chdir`, `rename`, - `link`, `symlink`, `chmod`, `chown`, `require`, and `do` - - Rejects tainted signal/PID arguments to `kill` - - Fixed interpreter eval behavior so a tainted eval cannot catch its own error - -- [x] **Phase 5: Regex capture untainting** (2026-08-09) - - Validated captures are clean scalars, matching Perl's standard untaint idiom - -- [x] **Cross-backend regression coverage** (2026-08-09) - - Added `src/test/resources/unit/taint_mode.t` (39 tests) - - Validated with system Perl, JVM backend, interpreter backend, and full `make` - -### Infrastructure Complete -- [x] `-T` flag parsing -- [x] `${^TAINT}` variable -- [x] Scalar taint metadata and `isTainted()` resolution -- [x] Central propagation and enforcement helpers -- [x] Thread-local taint mode for nested/concurrent execution - -### Next Steps - -1. Audit the remaining string, bitwise, comparison, list, and formatting - operators against `perl5_t/t/op/taint.t` and add propagation where Perl does. -2. Implement Perl's secure-`PATH` directory checks and nuanced `%ENV{TERM}` rules. -3. Expand external-source coverage for platform/network/database APIs as their - Perl-core taint cases become runnable. -4. Continue raising `perl5_t/t/op/taint.t` from the current early-stop baseline; - it presently reaches 23/1065 before unsupported environment/path semantics - stop the file. - -### Open Questions - -- Which reference/container operations should propagate value taint versus - preserve only the contained scalar's taint? -- Should the remaining operator audit be completed in one compatibility PR or - split by operator family to keep performance review tractable? +## Implementation Tracking + +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. diff --git a/src/main/java/org/perlonjava/runtime/operators/KillOperator.java b/src/main/java/org/perlonjava/runtime/operators/KillOperator.java index b62e64c50b..5c5ce44b52 100644 --- a/src/main/java/org/perlonjava/runtime/operators/KillOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/KillOperator.java @@ -3,6 +3,8 @@ import org.perlonjava.runtime.nativ.NativeUtils; import org.perlonjava.runtime.nativ.ffm.FFMPosix; import org.perlonjava.runtime.nativ.ffm.FFMPosixInterface; +import org.perlonjava.runtime.runtimetypes.GlobalContext; +import org.perlonjava.runtime.runtimetypes.PerlCompilerException; import org.perlonjava.runtime.runtimetypes.PerlSignalQueue; import org.perlonjava.runtime.runtimetypes.RuntimeBase; import org.perlonjava.runtime.runtimetypes.RuntimeScalar; @@ -25,7 +27,12 @@ public class KillOperator { * @return RuntimeScalar with count of successfully signaled processes */ public static RuntimeScalar kill(int ctx, RuntimeBase... args) { + boolean precedingJoinWasTainted = GlobalContext.consumeThreadJoinTaint(); if (args.length < 2) { + if (GlobalContext.isTaintModeActive() && precedingJoinWasTainted) { + throw new PerlCompilerException( + "Insecure dependency in kill while running with -T switch"); + } return new RuntimeScalar(0); } diff --git a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java index d17c7c8ac4..82eb601f01 100644 --- a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java @@ -792,6 +792,11 @@ public static RuntimeScalar join(RuntimeScalar runtimeScalar, RuntimeBase list) return joinInternal(runtimeScalar, list, true, false); } + private static RuntimeScalar recordJoinTaint(RuntimeScalar result) { + GlobalContext.setThreadJoinTaint(result.isTainted()); + return result; + } + /** * Internal join implementation with optional warning control. * Used for both explicit join() calls and string interpolation. @@ -820,7 +825,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa WarnDie.warnWithCategory(new RuntimeScalar("Use of uninitialized value in join or string"), RuntimeScalarCache.scalarEmptyString, "uninitialized"); } - return new RuntimeScalar(""); + return recordJoinTaint(new RuntimeScalar("")); } // Fast path: 1 element -> return that element (no separator evaluation needed) @@ -839,7 +844,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa res.type = BYTE_STRING; } res.tainted = resolved.isTainted(); - return res; + return recordJoinTaint(res); } // 2+ elements: evaluate the separator @@ -885,7 +890,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa res.type = BYTE_STRING; } res.tainted = tainted; - return res; + return recordJoinTaint(res); } /** diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index 6287db8af8..bdca0ad749 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -20,6 +20,8 @@ public class GlobalContext { private static final ThreadLocal threadTaintMode = ThreadLocal.withInitial(() -> Boolean.FALSE); + private static final ThreadLocal threadJoinTaint = + ThreadLocal.withInitial(() -> Boolean.FALSE); // Special variables internal names public static final String GLOBAL_PHASE = encodeSpecialVar("GLOBAL_PHASE"); // $^GLOBAL_PHASE @@ -35,6 +37,18 @@ public static boolean isTaintModeActive() { return threadTaintMode.get(); } + /** Record the taint state left by join(), used by Perl's legacy taint probe. */ + public static void setThreadJoinTaint(boolean tainted) { + threadJoinTaint.set(tainted); + } + + /** Return and clear the taint state left by the immediately preceding join(). */ + public static boolean consumeThreadJoinTaint() { + boolean tainted = threadJoinTaint.get(); + threadJoinTaint.set(false); + return tainted; + } + // Virtual directory names for JAR-embedded Perl resources // E.g., @INC contains "jar:PERL5LIB", %INC contains "jar:PERL5LIB/DBI.pm" public static final String JAR_PERLLIB = "jar:PERL5LIB"; // maps to /lib/ in JAR diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 8b22ce028f..bbaa75b901 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -28,6 +28,13 @@ ok(tainted(ord($text)), 'ord propagates taint'); $text =~ /^(.*)$/; ok(!tainted($1), 'regex capture untaints validated input'); +sub perlsec_tainted { + return !eval { no warnings; join('', @_), kill 0; 1 }; +} + +ok(perlsec_tainted($text), 'legacy perlsec join/kill probe detects taint'); +ok(!perlsec_tainted('clean'), 'legacy perlsec join/kill probe accepts clean data'); + my $eval_ok = eval { eval $text; 1 }; ok(!$eval_ok, 'tainted eval STRING is rejected'); like($@, qr/^Insecure dependency in eval while running with -T switch/, From 08d1c3db210a691b4abea6a37b06f753105c86ae Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 18:17:31 +0200 Subject: [PATCH 03/26] fix: preserve given topic and result semantics Localize the given topic so read-only foreach aliases remain assignable and restore correctly. Carry a matching when clause's final value across its synthetic control-flow exit in both JVM and interpreter backends without exposing the internal value to generic last-expression analysis. This lets perl5_t/t/op/taint.t complete all 1065 cases and preserves taint in the given result while avoiding an overload-analysis regression. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeCompiler.java | 22 ++++++++++++- .../backend/jvm/EmitControlFlow.java | 20 +++++++++-- .../backend/jvm/EmitOperatorNode.java | 2 +- .../frontend/parser/StatementParser.java | 33 +++++++++++++++---- src/test/resources/unit/taint_mode.t | 19 +++++++++++ 5 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java index a2dcc422c4..9df5c8e69a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeCompiler.java @@ -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); } @@ -6908,11 +6909,12 @@ Map 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; @@ -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 @@ -7078,6 +7096,7 @@ 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; @@ -7085,6 +7104,7 @@ private static class LoopInfo { 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<>(); diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java b/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java index ef9bc865a3..dcf981bf07 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitControlFlow.java @@ -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; @@ -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); } diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitOperatorNode.java b/src/main/java/org/perlonjava/backend/jvm/EmitOperatorNode.java index 17aecfefec..95658498d9 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitOperatorNode.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitOperatorNode.java @@ -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); diff --git a/src/main/java/org/perlonjava/frontend/parser/StatementParser.java b/src/main/java/org/perlonjava/frontend/parser/StatementParser.java index 5a96323363..23072b0598 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StatementParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/StatementParser.java @@ -531,11 +531,29 @@ public static Node parseWhenStatement(Parser parser) { BlockNode whenBlock = ParseBlock.parseBlock(parser); TokenUtils.consume(parser, LexerTokenType.OPERATOR, "}"); - // After a successful when match, Perl implicitly breaks out of the - // enclosing given block. Append `last;` to the when block so that - // execution leaves the surrounding bare-block (which acts as a - // single-iteration loop) once the matching block has run. - whenBlock.elements.add(new OperatorNode("last", new ListNode(index), index)); + // After a successful match, Perl returns the value of the when block + // and implicitly leaves the enclosing given block. Keep the final + // expression attached to our synthetic last so the backends can carry + // that value across the control-flow jump instead of compiling it in + // void context and replacing it with undef. + Node whenResult = null; + for (int i = whenBlock.elements.size() - 1; i >= 0; i--) { + Node element = whenBlock.elements.get(i); + if (element != null) { + whenResult = element; + whenBlock.elements.remove(i); + break; + } + } + if (whenResult == null) { + whenResult = new OperatorNode("undef", new ListNode(index), index); + } + OperatorNode implicitLast = new OperatorNode("last", new ListNode(index), index); + implicitLast.setAnnotation("implicitGivenLast", true); + // Store the value out-of-band so generic visitors never mistake it for + // a user-written dynamic label expression on `last EXPR`. + implicitLast.setAnnotation("implicitGivenResult", whenResult); + whenBlock.elements.add(implicitLast); // Determine whether to smart-match against $_ or use the value directly. // Per perlsyn, when(EXPR) skips the implicit `$_ ~~` and uses EXPR @@ -646,12 +664,13 @@ public static Node parseGivenStatement(Parser parser) { // Create the complete block: { $_ = EXPR; blockContent } List statements = new ArrayList<>(); - // $_ = condition (use proper $_ structure) + // local $_ = condition (given dynamically localizes the topic) Node dollarUnderscore = new OperatorNode("$", new IdentifierNode("_", index), index); + Node localTopic = new OperatorNode("local", dollarUnderscore, index); statements.add(new BinaryOperatorNode("=", - dollarUnderscore, + localTopic, condition, index)); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index bbaa75b901..f87ec7462a 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -1,6 +1,8 @@ #!perl -T use strict; use warnings; +use feature 'switch'; +no warnings 'experimental::smartmatch'; use Scalar::Util qw(tainted); use Test::More; @@ -79,4 +81,21 @@ for my $case (@file_operations) { "$operation reports the Perl security error"); } +for (qw(x y z)) { + my $outer_topic = $_; + my $letter = "$_$empty_taint"; + my $result = do { + no warnings 'deprecated'; + given ($_) { + when ('x') { $letter } + when ('y') { goto leave_given } + default { $letter } + leave_given: $letter + } + }; + is($result, $letter, "given preserves the result for $outer_topic"); + ok(tainted($result), "given preserves taint for $outer_topic"); + is($_, $outer_topic, "given restores the foreach topic for $outer_topic"); +} + done_testing; From 73f900de05a41b7661a7f017c2e34a5fea1798e2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 18:26:21 +0200 Subject: [PATCH 04/26] feat: propagate taint through scalar transforms Preserve taint through crypt, bitwise complement, split, vec, pack, and sprintf results. Reject tainted printf and sprintf format strings, and track only the arguments consumed by sprintf format directives. This advances perl5_t/t/op/taint.t from 820 to 852 passing cases while the test continues through all 1065 cases. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/BitwiseOperators.java | 16 ++++---- .../perlonjava/runtime/operators/Crypt.java | 2 +- .../runtime/operators/IOOperator.java | 1 + .../runtime/operators/Operator.java | 10 ++++- .../perlonjava/runtime/operators/Pack.java | 11 +++-- .../runtime/operators/SprintfOperator.java | 41 +++++++++++++++++++ .../org/perlonjava/runtime/operators/Vec.java | 24 +++++++---- src/test/resources/unit/taint_mode.t | 31 ++++++++++++++ 8 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java index b54cb195c2..a7590f0a5d 100644 --- a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java @@ -266,7 +266,7 @@ public static RuntimeScalar bitwiseNot(RuntimeScalar runtimeScalar) { if (blessId < 0) { RuntimeScalar result = OverloadContext.tryOneArgumentOverload( runtimeScalar, blessId, "(~", "~", BitwiseOperators::bitwiseNot); - if (result != null) return result; + if (result != null) return result.propagateTaint(runtimeScalar); } // Fetch tied/readonly scalar once to avoid redundant FETCH calls @@ -279,9 +279,9 @@ public static RuntimeScalar bitwiseNot(RuntimeScalar runtimeScalar) { // - If it's a string, use string NOT (character-by-character) int vt = val.type; if (vt == RuntimeScalarType.INTEGER || vt == RuntimeScalarType.DOUBLE) { - return bitwiseNotBinary(val); + return bitwiseNotBinary(val).propagateTaint(runtimeScalar); } - return bitwiseNotDot(val); + return bitwiseNotDot(val).propagateTaint(runtimeScalar); } /** @@ -303,7 +303,7 @@ public static RuntimeScalar bitwiseNotBinary(RuntimeScalar runtimeScalar) { // Apply bitwise NOT and mask to 32 bits long result = (~masked32bit) & 0xFFFFFFFFL; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(runtimeScalar); } /** @@ -319,7 +319,7 @@ public static RuntimeScalar integerBitwiseNot(RuntimeScalar runtimeScalar) { if (blessId < 0) { RuntimeScalar result = OverloadContext.tryOneArgumentOverload( runtimeScalar, blessId, "(~", "~", BitwiseOperators::integerBitwiseNot); - if (result != null) return result; + if (result != null) return result.propagateTaint(runtimeScalar); } // Fetch tied/readonly scalar once to avoid redundant FETCH calls @@ -332,7 +332,7 @@ public static RuntimeScalar integerBitwiseNot(RuntimeScalar runtimeScalar) { // - If it's a string, use string NOT (character-by-character) int vt = val.type; if (vt != RuntimeScalarType.INTEGER && vt != RuntimeScalarType.DOUBLE) { - return bitwiseNotDot(val); + return bitwiseNotDot(val).propagateTaint(runtimeScalar); } // Must use 32-bit int (not long) to match ivsize=4 in Config.pm. @@ -341,7 +341,7 @@ public static RuntimeScalar integerBitwiseNot(RuntimeScalar runtimeScalar) { int value = (int) val.getLong(); int result = ~value; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(runtimeScalar); } /** @@ -450,7 +450,7 @@ public static RuntimeScalar bitwiseNotDot(RuntimeScalar runtimeScalar) { // Perl's string complement returns an octet string even when the // operand carries the UTF-8 flag (for code points representable as // bytes, which is the range accepted above). - return stringBitwiseResult(result.toString(), false); + return stringBitwiseResult(result.toString(), false).propagateTaint(runtimeScalar); } private static RuntimeScalar stringBitwiseResult(String value, boolean utf8) { diff --git a/src/main/java/org/perlonjava/runtime/operators/Crypt.java b/src/main/java/org/perlonjava/runtime/operators/Crypt.java index 1aa191944e..f3891497d1 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Crypt.java +++ b/src/main/java/org/perlonjava/runtime/operators/Crypt.java @@ -50,7 +50,7 @@ public static RuntimeScalar crypt(RuntimeList args) { } String hashed = hashWithSalt(plaintext, salt); - return new RuntimeScalar(hashed); + return new RuntimeScalar(hashed).propagateTaint(plaintextScalar, saltScalar); } /** diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 5bf4152983..90aaecb7e8 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -954,6 +954,7 @@ public static RuntimeScalar printf(RuntimeList runtimeList, RuntimeScalar fileHa } RuntimeScalar format = (RuntimeScalar) flatList.elements.removeFirst(); // Extract the format string from elements + RuntimeScalar.checkTaint(format, "printf"); String formattedString; diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index b301903a0c..1f4350dcef 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -296,9 +296,17 @@ public static RuntimeList split(RuntimeScalar quotedRegex, RuntimeList args, int } } + if (string.isTainted()) { + for (RuntimeBase element : splitElements) { + if (element instanceof RuntimeScalar scalar) { + scalar.tainted = true; + } + } + } + if (ctx == SCALAR) { int size = result.elements.size(); - return getScalarInt(size).getList(); + return getScalarInt(size).propagateTaint(string).getList(); } return result; } diff --git a/src/main/java/org/perlonjava/runtime/operators/Pack.java b/src/main/java/org/perlonjava/runtime/operators/Pack.java index c04f5230d0..1a284d1e0b 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Pack.java +++ b/src/main/java/org/perlonjava/runtime/operators/Pack.java @@ -273,10 +273,15 @@ public static RuntimeScalar pack(RuntimeList args) { boolean shouldUpgrade = !result.byteModeUsed() && (result.hasUnicodeInNormalMode() || output.hasUnicodeCharacters()); - if (shouldUpgrade) { - return new RuntimeScalar(output.toUpgradedString()); + RuntimeScalar packed = shouldUpgrade + ? new RuntimeScalar(output.toUpgradedString()) + : new RuntimeScalar(output.toByteArray()); + for (RuntimeBase input : args.elements) { + if (input instanceof RuntimeScalar scalar) { + packed = packed.propagateTaint(scalar); + } } - return new RuntimeScalar(output.toByteArray()); + return packed; } public static PackResult packInto(String template, List values, int startValueIndex, diff --git a/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java b/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java index ce9da9d7fc..9d1ed41390 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java @@ -44,6 +44,7 @@ public static RuntimeScalar sprintfBytes(RuntimeScalar runtimeScalar, RuntimeLis } private static RuntimeScalar sprintfInternal(RuntimeScalar runtimeScalar, RuntimeList list, boolean bytesMode) { + RuntimeScalar.checkTaint(runtimeScalar, "sprintf"); charsWritten = 0; // Reset counter // Expand the list to ensure all elements are available list = new RuntimeList((RuntimeBase) list); @@ -66,6 +67,7 @@ private static RuntimeScalar sprintfInternal(RuntimeScalar runtimeScalar, Runtim boolean hasValidSpecifier = false; // Track if we have any valid specifiers boolean hasPositionalParameter = false; // Track if any positional parameters are used boolean hasInvalidSpecifier = false; // Track if any invalid specifiers were found + boolean hasTaintedArgument = false; // Parse the format string into literals and format specifiers SprintfFormatParser.ParseResult parsed = SprintfFormatParser.parse(format); @@ -174,6 +176,7 @@ private static RuntimeScalar sprintfInternal(RuntimeScalar runtimeScalar, Runtim ProcessResult processResult = processFormatSpecifierTracked(spec, list, argIndex, formatter, bytesMode); result.append(processResult.formatted); charsWritten += processResult.formatted.length(); + hasTaintedArgument |= usedArgumentIsTainted(spec, list, argIndex); // Only update maxArgIndexUsed if this specifier actually consumed arguments if (spec.conversionChar != '%' || spec.widthFromArg) { @@ -221,9 +224,47 @@ private static RuntimeScalar sprintfInternal(RuntimeScalar runtimeScalar, Runtim if (!hasUtf8Input) { res.type = RuntimeScalarType.BYTE_STRING; } + if (hasTaintedArgument) { + res.tainted = true; + } return res; } + private static boolean usedArgumentIsTainted(FormatSpecifier spec, RuntimeList list, int argIndex) { + java.util.LinkedHashSet used = new java.util.LinkedHashSet<>(); + int current = argIndex; + + if (spec.vectorFlag && spec.widthFromArg && spec.raw.matches(".*\\*v.*")) { + used.add(current++); // vector separator + if (spec.precisionFromArg) { + used.add(current++); // vector element width + } + used.add(current); // vector value + } else { + if (spec.widthFromArg) { + used.add(spec.widthArgIndex != null ? spec.widthArgIndex - 1 : current++); + } + if (spec.precisionFromArg) { + used.add(spec.precisionArgIndex != null ? spec.precisionArgIndex - 1 : current++); + } + if (spec.conversionChar != '%') { + used.add(spec.parameterIndex != null ? spec.parameterIndex - 1 : current); + } + if (spec.vectorFlag && spec.separatorArgIndex != null) { + used.add(spec.separatorArgIndex - 1); + } + } + + for (int index : used) { + if (index >= 0 && index < list.size() + && list.elements.get(index) instanceof RuntimeScalar scalar + && scalar.isTainted()) { + return true; + } + } + return false; + } + private static void handlePercentN(FormatSpecifier spec, RuntimeList list, int argIndex) { int targetIndex = spec.parameterIndex != null ? spec.parameterIndex - 1 : argIndex; diff --git a/src/main/java/org/perlonjava/runtime/operators/Vec.java b/src/main/java/org/perlonjava/runtime/operators/Vec.java index 1fd9105814..cfb2f043cd 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Vec.java +++ b/src/main/java/org/perlonjava/runtime/operators/Vec.java @@ -30,7 +30,7 @@ public static RuntimeScalar vec(RuntimeList args) throws PerlCompilerException { // Return 0 for undefined values without autovivifying int offset = ((RuntimeScalar) args.elements.get(1)).getInt(); int bits = ((RuntimeScalar) args.elements.get(2)).getInt(); - return new RuntimeVecLvalue(strScalar, offset, bits, 0); + return vecResult(strScalar, offset, bits, 0); } String str = strScalar.toString(); @@ -52,14 +52,14 @@ public static RuntimeScalar vec(RuntimeList args) throws PerlCompilerException { // Handle negative offset if (offset < 0) { - return new RuntimeVecLvalue(strScalar, offset, bits, 0); + return vecResult(strScalar, offset, bits, 0); } // Check for potential overflow in offset * bits calculation // Use long arithmetic to detect overflow long longByteOffset = ((long) offset * bits) / 8; if (longByteOffset > Integer.MAX_VALUE || longByteOffset >= data.length) { - return new RuntimeVecLvalue(strScalar, offset, bits, 0); + return vecResult(strScalar, offset, bits, 0); } int byteOffset = (int) longByteOffset; @@ -69,16 +69,16 @@ public static RuntimeScalar vec(RuntimeList args) throws PerlCompilerException { if (bits == 64 && byteOffset + 8 <= data.length) { long longValue = buffer.getLong(byteOffset); - return new RuntimeVecLvalue(strScalar, offset, bits, longValue); + return vecResult(strScalar, offset, bits, longValue); } else if (bits == 32 && byteOffset + 4 <= data.length) { long unsignedValue = Integer.toUnsignedLong(buffer.getInt(byteOffset)); - return new RuntimeVecLvalue(strScalar, offset, bits, unsignedValue); + return vecResult(strScalar, offset, bits, unsignedValue); } else if (bits == 16 && byteOffset + 2 <= data.length) { int value = buffer.getShort(byteOffset) & 0xFFFF; - return new RuntimeVecLvalue(strScalar, offset, bits, value); + return vecResult(strScalar, offset, bits, value); } else if (bits == 8 && byteOffset < data.length) { int value = buffer.get(byteOffset) & 0xFF; - return new RuntimeVecLvalue(strScalar, offset, bits, value); + return vecResult(strScalar, offset, bits, value); } else { int value = 0; for (int i = 0; i < bits; i++) { @@ -88,10 +88,16 @@ public static RuntimeScalar vec(RuntimeList args) throws PerlCompilerException { value |= ((data[byteIndex] >> bitIndex) & 1) << i; } } - return new RuntimeVecLvalue(strScalar, offset, bits, value); + return vecResult(strScalar, offset, bits, value); } } + private static RuntimeVecLvalue vecResult(RuntimeScalar source, int offset, int bits, long value) { + RuntimeVecLvalue result = new RuntimeVecLvalue(source, offset, bits, value); + result.tainted = source.isTainted(); + return result; + } + /** * Sets a bit field in a string to a specified value. * @@ -169,4 +175,4 @@ public static RuntimeScalar set(RuntimeList args, RuntimeScalar value) throws Pe ((RuntimeScalar) args.elements.getFirst()).set(new String(data, StandardCharsets.ISO_8859_1)); return value; } -} \ No newline at end of file +} diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index f87ec7462a..8c20fb7547 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -98,4 +98,35 @@ for (qw(x y z)) { is($_, $outer_topic, "given restores the foreach topic for $outer_topic"); } +my @split = split /!/, "left!right$empty_taint"; +ok(tainted($split[0]) && tainted($split[1]), 'split propagates input taint'); + +{ + no warnings 'numeric'; + ok(tainted(~("abc$empty_taint")), 'bitwise complement propagates taint'); +} +ok(tainted(crypt('secret', "aa$empty_taint")), 'crypt propagates salt taint'); +ok(tainted(vec("A$empty_taint", 0, 8)), 'vec propagates source taint'); +ok(tainted(pack('a*', "packed$empty_taint")), 'pack propagates value taint'); + +ok(tainted(sprintf('%s', "formatted$empty_taint", 'clean')), + 'sprintf propagates used argument taint'); +{ + no warnings 'numeric'; + my $tainted_zero = 0 + $empty_taint; + ok(!tainted(sprintf('%s', 'clean', $tainted_zero)), + 'sprintf ignores an unused tainted numeric argument'); +} + +my $tainted_format = "%s$empty_taint"; +my $sprintf_ok = eval { sprintf($tainted_format, 'value'); 1 }; +ok(!$sprintf_ok, 'sprintf rejects a tainted format'); +like($@, qr/^Insecure dependency in sprintf while running with -T switch/, + 'sprintf reports the Perl security error'); + +my $printf_ok = eval { printf($tainted_format, 'value'); 1 }; +ok(!$printf_ok, 'printf rejects a tainted format'); +like($@, qr/^Insecure dependency in printf while running with -T switch/, + 'printf reports the Perl security error'); + done_testing; From 9c0ce2b3512254a57b5b8989057b657bbd136471 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 18:35:51 +0200 Subject: [PATCH 05/26] feat: enforce taint checks for file mutations Reject tainted paths and flags for write-capable open and sysopen operations while retaining Perl's allowance for tainted read-only paths. Check truncate, utime, and chown inputs before early returns or exception handling, and preserve taint through binary bitwise flag expressions. This advances perl5_t/t/op/taint.t from 852 to 897 passing cases. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/BitwiseOperators.java | 31 +++++++------ .../runtime/operators/ChownOperator.java | 7 ++- .../runtime/operators/IOOperator.java | 33 +++++++++++-- .../runtime/operators/UtimeOperator.java | 4 ++ src/test/resources/unit/taint_mode.t | 46 +++++++++++++++++++ 5 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java index a7590f0a5d..1590bc9b21 100644 --- a/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/BitwiseOperators.java @@ -28,7 +28,7 @@ public static RuntimeScalar bitwiseAnd(RuntimeScalar runtimeScalar, RuntimeScala int t2 = arg2.type; if (t1 == RuntimeScalarType.INTEGER && t2 == RuntimeScalarType.INTEGER) { int result = ((int) runtimeScalar.value) & ((int) arg2.value); - return new RuntimeScalar(Integer.toUnsignedLong(result)); + return new RuntimeScalar(Integer.toUnsignedLong(result)).propagateTaint(runtimeScalar, arg2); } // Check for overloaded '&' operator on blessed objects @@ -36,7 +36,7 @@ public static RuntimeScalar bitwiseAnd(RuntimeScalar runtimeScalar, RuntimeScala int blessId2 = blessedId(arg2); if (blessId < 0 || blessId2 < 0) { RuntimeScalar result = OverloadContext.tryTwoArgumentOverload(runtimeScalar, arg2, blessId, blessId2, "(&", "&"); - if (result != null) return result; + if (result != null) return result.propagateTaint(runtimeScalar, arg2); } // Fetch tied/readonly scalars once to avoid redundant FETCH calls @@ -81,7 +81,7 @@ public static RuntimeScalar bitwiseAndBinary(RuntimeScalar runtimeScalar, Runtim long val2 = arg2.getLong() & 0xFFFFFFFFL; long result = (val1 & val2) & 0xFFFFFFFFL; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(runtimeScalar, arg2); } /** @@ -99,7 +99,7 @@ public static RuntimeScalar bitwiseOr(RuntimeScalar runtimeScalar, RuntimeScalar int t2 = arg2.type; if (t1 == RuntimeScalarType.INTEGER && t2 == RuntimeScalarType.INTEGER) { int result = ((int) runtimeScalar.value) | ((int) arg2.value); - return new RuntimeScalar(Integer.toUnsignedLong(result)); + return new RuntimeScalar(Integer.toUnsignedLong(result)).propagateTaint(runtimeScalar, arg2); } // Check for overloaded '|' operator on blessed objects @@ -107,7 +107,7 @@ public static RuntimeScalar bitwiseOr(RuntimeScalar runtimeScalar, RuntimeScalar int blessId2 = blessedId(arg2); if (blessId < 0 || blessId2 < 0) { RuntimeScalar result = OverloadContext.tryTwoArgumentOverload(runtimeScalar, arg2, blessId, blessId2, "(|", "|"); - if (result != null) return result; + if (result != null) return result.propagateTaint(runtimeScalar, arg2); } // Fetch tied/readonly scalars once to avoid redundant FETCH calls @@ -142,7 +142,7 @@ public static RuntimeScalar bitwiseOrBinary(RuntimeScalar runtimeScalar, Runtime long val2 = arg2.getLong() & 0xFFFFFFFFL; long result = (val1 | val2) & 0xFFFFFFFFL; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(runtimeScalar, arg2); } /** @@ -164,7 +164,7 @@ public static RuntimeScalar bitwiseXor(RuntimeScalar runtimeScalar, RuntimeScala int t2 = arg2.type; if (t1 == RuntimeScalarType.INTEGER && t2 == RuntimeScalarType.INTEGER) { int result = ((int) runtimeScalar.value) ^ ((int) arg2.value); - return new RuntimeScalar(Integer.toUnsignedLong(result)); + return new RuntimeScalar(Integer.toUnsignedLong(result)).propagateTaint(runtimeScalar, arg2); } // Check for overloaded '^' operator on blessed objects @@ -172,7 +172,7 @@ public static RuntimeScalar bitwiseXor(RuntimeScalar runtimeScalar, RuntimeScala int blessId2 = blessedId(arg2); if (blessId < 0 || blessId2 < 0) { RuntimeScalar result = OverloadContext.tryTwoArgumentOverload(runtimeScalar, arg2, blessId, blessId2, "(^", "^"); - if (result != null) return result; + if (result != null) return result.propagateTaint(runtimeScalar, arg2); } // Fetch tied/readonly scalars once to avoid redundant FETCH calls @@ -207,7 +207,7 @@ public static RuntimeScalar bitwiseXorBinary(RuntimeScalar runtimeScalar, Runtim long val2 = arg2.getLong() & 0xFFFFFFFFL; long result = (val1 ^ val2) & 0xFFFFFFFFL; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(runtimeScalar, arg2); } /** Numeric bitwise operations under {@code use integer}: return a signed IV. */ @@ -230,7 +230,7 @@ private static RuntimeScalar integerBitwiseBinary(RuntimeScalar arg1, RuntimeSca String symbol = Character.toString(operator); RuntimeScalar overloaded = OverloadContext.tryTwoArgumentOverload( arg1, arg2, blessId, blessId2, "(" + symbol, symbol); - if (overloaded != null) return overloaded; + if (overloaded != null) return overloaded.propagateTaint(arg1, arg2); } int a = nativeIntValue(arg1); int b = nativeIntValue(arg2); @@ -240,7 +240,7 @@ private static RuntimeScalar integerBitwiseBinary(RuntimeScalar arg1, RuntimeSca case '^' -> a ^ b; default -> throw new IllegalArgumentException("unknown bitwise operator: " + operator); }; - return new RuntimeScalar(result); + return new RuntimeScalar(result).propagateTaint(arg1, arg2); } private static int nativeIntValue(RuntimeScalar value) { @@ -369,7 +369,8 @@ public static RuntimeScalar bitwiseAndDot(RuntimeScalar runtimeScalar, RuntimeSc return stringBitwiseResult(result.toString(), runtimeScalar.type == RuntimeScalarType.STRING - || arg2.type == RuntimeScalarType.STRING); + || arg2.type == RuntimeScalarType.STRING) + .propagateTaint(runtimeScalar, arg2); } /** @@ -397,7 +398,8 @@ public static RuntimeScalar bitwiseOrDot(RuntimeScalar runtimeScalar, RuntimeSca return stringBitwiseResult(result.toString(), runtimeScalar.type == RuntimeScalarType.STRING - || arg2.type == RuntimeScalarType.STRING); + || arg2.type == RuntimeScalarType.STRING) + .propagateTaint(runtimeScalar, arg2); } /** @@ -425,7 +427,8 @@ public static RuntimeScalar bitwiseXorDot(RuntimeScalar runtimeScalar, RuntimeSc return stringBitwiseResult(result.toString(), runtimeScalar.type == RuntimeScalarType.STRING - || arg2.type == RuntimeScalarType.STRING); + || arg2.type == RuntimeScalarType.STRING) + .propagateTaint(runtimeScalar, arg2); } /** diff --git a/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java b/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java index 7bca0e92d7..6d5ec98ad5 100644 --- a/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/ChownOperator.java @@ -21,6 +21,12 @@ public class ChownOperator { * @return RuntimeScalar with count of successfully changed files */ public static RuntimeScalar chown(int ctx, RuntimeBase... args) { + for (RuntimeBase arg : args) { + for (RuntimeScalar scalar : arg) { + RuntimeScalar.checkTaint(scalar, "chown"); + } + } + if (args.length < 2) { // Need at least uid and gid return new RuntimeScalar(0); @@ -47,7 +53,6 @@ public static RuntimeScalar chown(int ctx, RuntimeBase... args) { // Handle both scalar filenames and lists of filenames for (RuntimeScalar fileArg : arg) { - RuntimeScalar.checkTaint(fileArg, "chown"); boolean result = false; try { // Check if this is a filehandle (glob reference) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 90aaecb7e8..4a7dd6c3c7 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -694,7 +694,7 @@ public static RuntimeScalar open(int ctx, RuntimeBase... args) { if (mode.contains("|")) { for (int i = 1; i < args.length; i++) { - RuntimeScalar.checkTaint(args[i].scalar(), "open"); + RuntimeScalar.checkTaint(args[i].scalar(), "piped open"); } // Check for fork-open pattern: open FH, "-|" or open FH, "|-" with no command // This is the 2-arg piped open that normally forks in Perl @@ -713,6 +713,12 @@ public static RuntimeScalar open(int ctx, RuntimeBase... args) { } else if (args.length > 2) { // 3-argument open RuntimeScalar secondArg = args[2].scalar(); + boolean canWrite = mode.contains(">") || mode.startsWith("+"); + + if (canWrite) { + RuntimeScalar.checkTaint(args[1].scalar(), "open"); + RuntimeScalar.checkTaint(secondArg, "open"); + } // Check for filehandle duplication modes (<&, >&, >>&, +<&, +>&, +>>& and &= variants) if (mode.equals("<&") || mode.equals(">&") || mode.equals(">>&") || @@ -835,6 +841,9 @@ else if (secondArg.type == RuntimeScalarType.GLOB || secondArg.type == RuntimeSc } } else { // 2-argument open + if (mode.startsWith(">") || mode.startsWith("+") || mode.startsWith("|")) { + RuntimeScalar.checkTaint(args[1].scalar(), "open"); + } fh = RuntimeIO.open(mode); } if (fh == null) { @@ -1485,8 +1494,10 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { } RuntimeScalar fileHandle = args[0].scalar(); - String fileName = args[1].toString(); - int mode = args[2].scalar().getInt(); + RuntimeScalar fileNameArg = args[1].scalar(); + RuntimeScalar modeArg = args[2].scalar(); + String fileName = fileNameArg.toString(); + int mode = modeArg.getInt(); int perms = 0666; // Default permissions (octal) if (args.length >= 4) { @@ -1506,6 +1517,17 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { int O_TRUNC = 01000; // 512 in decimal int O_NOFOLLOW = 0400000; // Reject a symlink in the final path component + int baseMode = mode & 3; // Get the lowest 2 bits + boolean canWrite = baseMode != O_RDONLY + || (mode & (O_CREAT | O_APPEND | O_TRUNC)) != 0; + if (canWrite) { + RuntimeScalar.checkTaint(fileNameArg, "sysopen"); + RuntimeScalar.checkTaint(modeArg, "sysopen"); + if (args.length >= 4) { + RuntimeScalar.checkTaint(args[3].scalar(), "sysopen"); + } + } + File file = RuntimeIO.resolveFile(fileName); if ((mode & O_NOFOLLOW) != 0 && Files.isSymbolicLink(file.toPath())) { getGlobalVariable("main::!").set("Too many levels of symbolic links"); @@ -1513,8 +1535,6 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { } // Determine the base mode - int baseMode = mode & 3; // Get the lowest 2 bits - if (baseMode == O_RDONLY) { modeStr = "<"; } else if (baseMode == O_WRONLY) { @@ -2285,6 +2305,9 @@ public static RuntimeScalar truncate(int ctx, RuntimeBase... args) { return scalarFalse; } + RuntimeScalar.checkTaint(args[0].scalar(), "truncate"); + RuntimeScalar.checkTaint(args[1].scalar(), "truncate"); + try { RuntimeBase firstArg = args[0]; long length = args[1].scalar().getLong(); diff --git a/src/main/java/org/perlonjava/runtime/operators/UtimeOperator.java b/src/main/java/org/perlonjava/runtime/operators/UtimeOperator.java index 2bbe38ed97..3abb6a7e35 100644 --- a/src/main/java/org/perlonjava/runtime/operators/UtimeOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/UtimeOperator.java @@ -26,6 +26,10 @@ public static RuntimeScalar utime(int ctx, RuntimeBase... args) { } } + for (RuntimeScalar scalar : flat) { + RuntimeScalar.checkTaint(scalar, "utime"); + } + if (flat.size() < 3) { return new RuntimeScalar(0); } diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 8c20fb7547..1bce4667ea 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -3,6 +3,7 @@ use strict; use warnings; use feature 'switch'; no warnings 'experimental::smartmatch'; +use Fcntl qw(O_RDONLY O_WRONLY); use Scalar::Util qw(tainted); use Test::More; @@ -81,6 +82,51 @@ for my $case (@file_operations) { "$operation reports the Perl security error"); } +my $read_open_ok = eval { open my $fh, '<', $tainted_path; 1 }; +ok($read_open_ok, 'three-argument open permits a tainted read path'); +my $write_open_ok = eval { open my $fh, '>', $tainted_path; 1 }; +ok(!$write_open_ok, 'three-argument open rejects a tainted write path'); +like($@, qr/^Insecure dependency in open while running with -T switch/, + 'write open reports the Perl security error'); + +my $sysread_open_ok = eval { sysopen my $fh, $tainted_path, O_RDONLY; 1 }; +ok($sysread_open_ok, 'sysopen permits a tainted read-only path'); +my $syswrite_open_ok = eval { sysopen my $fh, $tainted_path, O_WRONLY; 1 }; +ok(!$syswrite_open_ok, 'sysopen rejects a tainted write path'); +like($@, qr/^Insecure dependency in sysopen while running with -T switch/, + 'sysopen reports the Perl security error'); + +{ + no warnings 'numeric'; + my $tainted_zero = 0 + $empty_taint; + ok(tainted(O_WRONLY | $tainted_zero), 'bitwise flags preserve taint'); + my $tainted_read_mode = O_RDONLY | $tainted_zero; + my $tainted_read_ok = eval { + sysopen my $fh, '/tmp/perlonjava-taint-no-such', $tainted_read_mode; + 1; + }; + ok($tainted_read_ok, 'sysopen permits tainted read-only flags'); + my $tainted_write_mode = O_WRONLY | $tainted_zero; + my $tainted_write_ok = eval { + sysopen my $fh, '/tmp/perlonjava-taint-no-such', $tainted_write_mode; + 1; + }; + ok(!$tainted_write_ok, 'sysopen rejects tainted write flags'); + like($@, qr/^Insecure dependency in sysopen while running with -T switch/, + 'tainted sysopen flags report the Perl security error'); + for my $case ( + [ truncate => sub { truncate '/tmp/perlonjava-taint-no-such', $tainted_zero } ], + [ utime => sub { utime $tainted_zero, $tainted_zero, '/tmp/perlonjava-taint-no-such' } ], + [ chown => sub { chown -1, -1, $tainted_path } ], + ) { + my ($operation, $code) = @$case; + my $ok = eval { $code->(); 1 }; + ok(!$ok, "$operation rejects tainted arguments"); + like($@, qr/^Insecure dependency in $operation while running with -T switch/, + "$operation reports the Perl security error"); + } +} + for (qw(x y z)) { my $outer_topic = $_; my $letter = "$_$empty_taint"; From e68526190366bfd461a3f94980909f91760a910a Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 18:51:13 +0200 Subject: [PATCH 06/26] feat: preserve regex taint semantics Propagate dynamic pattern taint into captures and compiled regex values, and implement lexical use re 'taint' capture behavior. Keep ordinary captures as an untainting boundary and match booleans clean. Core op/taint.t improves from 897 to 928 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../frontend/parser/StringParser.java | 4 ++++ .../org/perlonjava/runtime/perlmodule/Re.java | 4 ++++ .../perlonjava/runtime/perlmodule/Strict.java | 1 + .../perlonjava/runtime/regex/RegexFlags.java | 11 +++++++---- .../runtime/regex/RuntimeRegex.java | 19 +++++++++++++------ .../runtimetypes/ScalarSpecialVariable.java | 3 ++- src/test/resources/unit/taint_mode.t | 11 +++++++++++ 7 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/perlonjava/frontend/parser/StringParser.java b/src/main/java/org/perlonjava/frontend/parser/StringParser.java index d72bffc539..a037aa07de 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StringParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/StringParser.java @@ -19,6 +19,7 @@ import static org.perlonjava.runtime.perlmodule.Strict.HINT_RE_ASCII; import static org.perlonjava.runtime.perlmodule.Strict.HINT_RE_EVAL; import static org.perlonjava.runtime.perlmodule.Strict.HINT_RE_UNICODE; +import static org.perlonjava.runtime.perlmodule.Strict.HINT_RE_TAINT; import static org.perlonjava.runtime.perlmodule.Strict.HINT_LOCALE; import static org.perlonjava.runtime.runtimetypes.NameNormalizer.normalizeVariableName; import static org.perlonjava.runtime.runtimetypes.ScalarUtils.printable; @@ -593,6 +594,9 @@ public static OperatorNode parseRegexMatch(EmitterContext ctx, String operator, if (ctx.symbolTable.isStrictOptionEnabled(HINT_RE_EVAL) && !modStr.contains("E")) { modStr = "E" + modStr; } + if (ctx.symbolTable.isStrictOptionEnabled(HINT_RE_TAINT) && !modStr.contains("T")) { + modStr = "T" + modStr; + } } Node parsed = parseRegexString(ctx, rawStr, parser, modStr, isQuoteRegex); diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Re.java b/src/main/java/org/perlonjava/runtime/perlmodule/Re.java index 81e5b35ba2..dd5acfb80a 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Re.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Re.java @@ -166,6 +166,8 @@ public static RuntimeList importRe(RuntimeArray args, int ctx) { Warnings.warningManager.enableWarning("experimental::vlb"); } else if (opt.equalsIgnoreCase("eval")) { symbolTable.enableStrictOption(Strict.HINT_RE_EVAL); + } else if (opt.equalsIgnoreCase("taint")) { + symbolTable.enableStrictOption(Strict.HINT_RE_TAINT); } else if (opt.equals("/a")) { // use re '/a' - ASCII-restrict regex character classes symbolTable.enableStrictOption(Strict.HINT_RE_ASCII); @@ -199,6 +201,8 @@ public static RuntimeList unimportRe(RuntimeArray args, int ctx) { Warnings.warningManager.disableWarning("experimental::vlb"); } else if (opt.equalsIgnoreCase("eval")) { symbolTable.disableStrictOption(Strict.HINT_RE_EVAL); + } else if (opt.equalsIgnoreCase("taint")) { + symbolTable.disableStrictOption(Strict.HINT_RE_TAINT); } else if (opt.equals("/a") || opt.equals("/aa")) { symbolTable.disableStrictOption(Strict.HINT_RE_ASCII | Strict.HINT_RE_ASCII_AA); } else if (opt.equals("/u")) { diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java b/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java index debd92f2e5..f0cd166605 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Strict.java @@ -39,6 +39,7 @@ public class Strict extends PerlModuleBase { public static final int HINT_RE_UNICODE = 0x02000000; // use re '/u' public static final int HINT_RE_ASCII_AA = 0x04000000; // use re '/aa' public static final int HINT_RE_EVAL = 0x08000000; // use re 'eval' + public static final int HINT_RE_TAINT = 0x10000000; // use re 'taint' /** * Constructor for Strict. diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java index de975c1e5f..7d50ed3039 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java +++ b/src/main/java/org/perlonjava/runtime/regex/RegexFlags.java @@ -25,7 +25,7 @@ public record RegexFlags(boolean isGlobalMatch, boolean keepCurrentPosition, boo boolean isMatchExactlyOnce, boolean useGAssertion, boolean isExtendedWhitespace, boolean isNonCapturing, boolean isOptimized, boolean isCaseInsensitive, boolean isMultiLine, boolean isDotAll, boolean isExtended, boolean preservesMatch, boolean isUnicode, - boolean isAscii, boolean allowEvalGroup) { + boolean isAscii, boolean allowEvalGroup, boolean taintResults) { public static RegexFlags fromModifiers(String modifiers, String patternString) { // m?PAT? is encoded by StringParser as an extra trailing '?' on the modifier string @@ -48,13 +48,14 @@ public static RegexFlags fromModifiers(String modifiers, String patternString) { modifiers.contains("p"), modifiers.contains("u"), modifiers.contains("a"), - modifiers.contains("E") + modifiers.contains("E"), + modifiers.contains("T") ); } public static void validateModifiers(String modifiers) { // Valid modifiers based on what's actually handled in fromModifiers - String validModifiers = "gcr?noimsxpadeulE"; // Add 'xx' handling separately, 'l' for locale, 'E' for internal re eval + String validModifiers = "gcr?noimsxpadeulET"; // E/T are internal lexical flags for (int i = 0; i < modifiers.length(); i++) { char modifier = modifiers.charAt(i); @@ -149,7 +150,8 @@ public RegexFlags with(String positiveFlags, String negativeFlags) { newPreservesMatch, newIsUnicode, newIsAscii, - this.allowEvalGroup + this.allowEvalGroup, + this.taintResults ); } @@ -166,6 +168,7 @@ public String toFlagString() { if (isExtended) flagString.append('x'); if (isNonCapturing) flagString.append('n'); if (isNonDestructive) flagString.append('r'); + if (taintResults) flagString.append('T'); return flagString.toString(); } diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index af7d74cfe5..88c2951695 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -73,6 +73,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { // Track whether the last successful match was on a BYTE_STRING input, // so that captures ($1, $2, $&, etc.) preserve BYTE_STRING type. public static boolean lastMatchWasByteString = false; + public static boolean lastMatchResultsTainted = false; public static int[] manualCaptureStarts = null; public static int[] manualCaptureEnds = null; // Compiled regex pattern (for byte strings - ASCII-only \w, \d) @@ -896,7 +897,7 @@ public static RuntimeScalar getQuotedRegex(RuntimeScalar patternString, RuntimeS regex.patternFlags = regex.regexFlags.toPatternFlags(); regex.refCount = 0; // Track for proper weak ref handling - return new RuntimeScalar(regex); + return new RuntimeScalar(regex).propagateTaint(patternString); } // Check for qr overloading @@ -929,20 +930,22 @@ public static RuntimeScalar getQuotedRegex(RuntimeScalar patternString, RuntimeS regex.patternFlags = regex.regexFlags.toPatternFlags(); regex.refCount = 0; // Track for proper weak ref handling - return new RuntimeScalar(regex); + return new RuntimeScalar(regex).propagateTaint(patternString, overloadedResult); } // Try fallback to string conversion RuntimeScalar fallbackResult = overloadCtx.tryOverloadFallback(patternString, "(\"\""); if (fallbackResult != null) { - return new RuntimeScalar(compile(fallbackResult.toString(), modifierStr).cloneTracked()); + return new RuntimeScalar(compile(fallbackResult.toString(), modifierStr).cloneTracked()) + .propagateTaint(patternString, fallbackResult); } } } // Default: compile as string (cloneTracked() creates a tracked copy // so the cached RuntimeRegex is not corrupted by refCount changes) - return new RuntimeScalar(compile(patternString.toString(), modifierStr).cloneTracked()); + return new RuntimeScalar(compile(patternString.toString(), modifierStr).cloneTracked()) + .propagateTaint(patternString); } /** @@ -1053,7 +1056,7 @@ public static RuntimeScalar getReplacementRegex(RuntimeScalar patternString, Run } regex.replacement = replacement; - return new RuntimeScalar(regex); + return new RuntimeScalar(regex).propagateTaint(patternString); } /** @@ -1405,6 +1408,8 @@ private static RuntimeBase matchRegexDirect(RuntimeScalar quotedRegex, RuntimeSc } found = true; + lastMatchResultsTainted = quotedRegex.isTainted() + || (regex.regexFlags.taintResults() && string.isTainted()); lastMatchWasByteString = (string.type == RuntimeScalarType.BYTE_STRING); int captureCount = matcher.groupCount(); @@ -2432,6 +2437,7 @@ public static RuntimeScalar makeMatchResultScalar(String value) { if (lastMatchWasByteString) { scalar.type = RuntimeScalarType.BYTE_STRING; } + scalar.tainted = lastMatchResultsTainted; return scalar; } @@ -2605,7 +2611,8 @@ private static RegexFlags mergeOperationFlags(RegexFlags baseFlags, String modif base.preservesMatch() || operation.preservesMatch(), base.isUnicode(), base.isAscii(), - base.allowEvalGroup() || operation.allowEvalGroup() + base.allowEvalGroup() || operation.allowEvalGroup(), + base.taintResults() || operation.taintResults() ); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java index 0fadba55fd..c6e50d30d1 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ScalarSpecialVariable.java @@ -221,7 +221,7 @@ public RuntimeScalar getValueAsScalar() { } case LAST_PAREN_MATCH -> { String lastCapture = RuntimeRegex.lastCaptureString(); - yield lastCapture != null ? new RuntimeScalar(lastCapture) : scalarUndef; + yield lastCapture != null ? makeRegexResultScalar(lastCapture) : scalarUndef; } case LAST_SUCCESSFUL_PATTERN -> RuntimeRegex.lastSuccessfulPattern != null ? new RuntimeScalar(RuntimeRegex.lastSuccessfulPattern) : scalarUndef; @@ -500,6 +500,7 @@ private static RuntimeScalar makeRegexResultScalar(String value) { if (RuntimeRegex.lastMatchWasByteString) { scalar.type = RuntimeScalarType.BYTE_STRING; } + scalar.tainted = RuntimeRegex.lastMatchResultsTainted; return scalar; } diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 1bce4667ea..6568625832 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -31,6 +31,17 @@ ok(tainted(ord($text)), 'ord propagates taint'); $text =~ /^(.*)$/; ok(!tainted($1), 'regex capture untaints validated input'); +my $tainted_pattern = "(abc)$empty_taint"; +'abc' =~ /$tainted_pattern/; +ok(tainted($1), 'a tainted regex pattern taints its capture'); +ok(tainted(qr/$tainted_pattern/), 'qr preserves pattern taint'); + +{ + use re 'taint'; + $text =~ /^(.*)$/; + ok(tainted($1), q{use re 'taint' preserves input taint in captures}); +} + sub perlsec_tainted { return !eval { no warnings; join('', @_), kill 0; 1 }; } From eef32f60a5800ee440f6ee66d8b3faff40d05218 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:01:02 +0200 Subject: [PATCH 07/26] feat: propagate taint through substitutions Track source, pattern, replacement, capture, target, copy, and global count taint independently for s///. Preserve replacement overload taint, lexical use re 'taint', and observable /ge target state between matches. Core op/taint.t improves from 928 to 1003 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../frontend/parser/StringParser.java | 5 ++ .../runtime/regex/RuntimeRegex.java | 60 +++++++++++++++---- src/test/resources/unit/taint_mode.t | 35 +++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/perlonjava/frontend/parser/StringParser.java b/src/main/java/org/perlonjava/frontend/parser/StringParser.java index a037aa07de..526d6b579c 100644 --- a/src/main/java/org/perlonjava/frontend/parser/StringParser.java +++ b/src/main/java/org/perlonjava/frontend/parser/StringParser.java @@ -521,6 +521,11 @@ public static OperatorNode parseRegexReplace(EmitterContext ctx, ParsedString ra String operator = "replaceRegex"; String replaceStr = rawStr.buffers.get(1); String modifierStr = rawStr.buffers.get(2); + if (ctx.symbolTable != null + && ctx.symbolTable.isStrictOptionEnabled(HINT_RE_TAINT) + && !modifierStr.contains("T")) { + modifierStr = "T" + modifierStr; + } Node parsed = parseRegexString(ctx, rawStr, parser, modifierStr); Node replace; diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index 88c2951695..c333fb88fb 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -1992,8 +1992,10 @@ private static void setSubstitutionRegion(Matcher matcher, int start, int end, b } private static void updateReplacementMatchState(RuntimeRegex regex, Matcher matcher, - String inputStr, RuntimeScalar string) { + String inputStr, RuntimeScalar string, + boolean resultsTainted) { lastMatchWasByteString = (string.type == RuntimeScalarType.BYTE_STRING); + lastMatchResultsTainted = resultsTainted; // Initialize $1, $2, @+, @- only when we have a match globalMatcher = matcher; @@ -2041,6 +2043,8 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar // Convert the input string to a Java string String inputStr = string.toString(); boolean wasByteString = (string.type == RuntimeScalarType.BYTE_STRING); + boolean inputTainted = string.isTainted(); + boolean patternTainted = quotedRegex.isTainted(); boolean resultNeedsUtf8 = !wasByteString; // Extract the regex pattern from the quotedRegex object @@ -2158,6 +2162,10 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar // Determine if the replacement is a code that needs to be evaluated boolean replacementIsCode = (replacement.type == RuntimeScalarType.CODE); + boolean replacementResultTainted = false; + boolean captureResultsTainted = patternTainted + || (regex.regexFlags.taintResults() && inputTainted); + boolean destructiveReplacement = !regex.regexFlags.isNonDestructive(); // Don't reset globalMatcher here - only reset it if we actually find a match // This preserves capture variables from previous matches when substitution doesn't match @@ -2177,7 +2185,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar } found++; - updateReplacementMatchState(regex, matcher, inputStr, string); + updateReplacementMatchState(regex, matcher, inputStr, string, captureResultsTainted); String replacementStr; if (replacementIsCode) { @@ -2185,16 +2193,25 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar // Use callerArgs (the enclosing subroutine's @_) so $_[0] etc. work RuntimeArray args = (callerArgs != null) ? callerArgs : new RuntimeArray(); RuntimeList result = RuntimeCode.apply(replacement, args, RuntimeContextType.SCALAR); - if (Utf8.isUtf8(result.scalar())) { + RuntimeScalar replacementValue = stringifyReplacementValue(result.scalar()); + if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementStr = result.toString(); + replacementResultTainted |= replacementValue.isTainted(); + replacementStr = replacementValue.toString(); } else { // Replace the match with the replacement string - if (Utf8.isUtf8(replacement)) { + RuntimeScalar replacementValue = stringifyReplacementValue(replacement); + if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementStr = replacement.toString(); + replacementResultTainted |= replacementValue.isTainted(); + replacementStr = replacementValue.toString(); + } + + if (destructiveReplacement + && (inputTainted || patternTainted || replacementResultTainted)) { + string.tainted = true; } if (replacementStr != null) { @@ -2233,21 +2250,30 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar && retryMatcher.start() == zeroLengthOffset && retryMatcher.end() > zeroLengthOffset) { found++; - updateReplacementMatchState(regex, retryMatcher, inputStr, string); + updateReplacementMatchState(regex, retryMatcher, inputStr, string, captureResultsTainted); String retryReplacementStr; if (replacementIsCode) { RuntimeArray args = (callerArgs != null) ? callerArgs : new RuntimeArray(); RuntimeList result = RuntimeCode.apply(replacement, args, RuntimeContextType.SCALAR); - if (Utf8.isUtf8(result.scalar())) { + RuntimeScalar replacementValue = stringifyReplacementValue(result.scalar()); + if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - retryReplacementStr = result.toString(); + replacementResultTainted |= replacementValue.isTainted(); + retryReplacementStr = replacementValue.toString(); } else { - if (Utf8.isUtf8(replacement)) { + RuntimeScalar replacementValue = stringifyReplacementValue(replacement); + if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - retryReplacementStr = replacement.toString(); + replacementResultTainted |= replacementValue.isTainted(); + retryReplacementStr = replacementValue.toString(); + } + + if (destructiveReplacement + && (inputTainted || patternTainted || replacementResultTainted)) { + string.tainted = true; } if (retryReplacementStr != null) { @@ -2299,6 +2325,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar if (regex.regexFlags.isNonDestructive()) { // /r modifier: return the modified string RuntimeScalar rv = new RuntimeScalar(finalResult); + rv.tainted = inputTainted || patternTainted || replacementResultTainted; if (wasByteString && !resultNeedsUtf8 && !containsWideChars(finalResult)) { rv.type = RuntimeScalarType.BYTE_STRING; } @@ -2306,11 +2333,16 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar } else { // Save the modified string back to the original scalar string.set(finalResult); + string.tainted = inputTainted || patternTainted || replacementResultTainted; if (wasByteString && !resultNeedsUtf8 && !containsWideChars(finalResult)) { string.type = RuntimeScalarType.BYTE_STRING; } // Return the number of substitutions made - return RuntimeScalarCache.getScalarInt(found); + RuntimeScalar count = RuntimeScalarCache.getScalarInt(found); + if (regex.regexFlags.isGlobalMatch() && (inputTainted || patternTainted)) { + count = count.propagateTaint(string, quotedRegex); + } + return count; } } else { if (regex.regexFlags.isNonDestructive()) { @@ -2324,6 +2356,10 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar } } + private static RuntimeScalar stringifyReplacementValue(RuntimeScalar value) { + return RuntimeScalarType.blessedId(value) != 0 ? Overload.stringify(value) : value; + } + /** * Method to implement Perl's reset() function. * Resets the `matched` flag for each cached regex. diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 6568625832..8da7676906 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -42,6 +42,41 @@ ok(tainted(qr/$tainted_pattern/), 'qr preserves pattern taint'); ok(tainted($1), q{use re 'taint' preserves input taint in captures}); } +my $sub_source = "abcd$empty_taint"; +my $sub_count = $sub_source =~ s/(.+)/xyz/; +ok(tainted($sub_source), 'substitution preserves source taint on the target'); +ok(!tainted($sub_count), 'single substitution count stays clean'); +ok(!tainted($1), 'ordinary substitution capture untaints input'); + +$sub_source = "abcd$empty_taint"; +$sub_count = $sub_source =~ s/(.)/x/g; +ok(tainted($sub_count), 'global substitution count depends on tainted input'); + +my $sub_pattern = "(.+)$empty_taint"; +my $sub_target = 'abcd'; +$sub_count = $sub_target =~ s/$sub_pattern/xyz/; +ok(tainted($sub_target), 'tainted substitution pattern taints the target'); +ok(!tainted($sub_count), 'single substitution count ignores pattern taint'); +ok(tainted($1), 'tainted substitution pattern taints captures'); + +my $sub_replacement = "xyz$empty_taint"; +$sub_target = 'abcd'; +$sub_count = $sub_target =~ s/(.+)/$sub_replacement/; +ok(tainted($sub_target), 'tainted replacement taints the substitution target'); +ok(!tainted($sub_count), 'substitution count ignores replacement taint'); + +$sub_target = 'abcd'; +my $sub_copy = $sub_target =~ s/(.+)/$sub_replacement/r; +ok(!tainted($sub_target), 'non-destructive substitution leaves its source clean'); +ok(tainted($sub_copy), 'non-destructive substitution preserves replacement taint'); + +{ + use re 'taint'; + $sub_source = "abcd$empty_taint"; + $sub_source =~ s/(.+)/xyz/; + ok(tainted($1), q{use re 'taint' taints substitution captures from tainted input}); +} + sub perlsec_tainted { return !eval { no warnings; join('', @_), kill 0; 1 }; } From 59ee5aac16d75cde4cbbf832fd17041912099016 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:10:00 +0200 Subject: [PATCH 08/26] fix: preserve taint from stringification overloads Retain taint on scalar results returned by overloaded stringification across single and mixed interpolation, join, concatenation fallback, and regex replacement evaluation. Core op/taint.t improves from 1003 to 1013 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/StringOperators.java | 21 ++++++++++++------- src/test/resources/unit/taint_mode.t | 16 ++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java index 82eb601f01..5f82ab5cf0 100644 --- a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java @@ -497,8 +497,8 @@ public static RuntimeScalar stringConcat(RuntimeScalar runtimeScalar, RuntimeSca RuntimeScalar overloaded = tryStringConcatOverload(runtimeScalar, b); if (overloaded != null) return overloaded; - RuntimeScalar aResolved = resolveTiedStringOperand(runtimeScalar); - RuntimeScalar bResolved = resolveTiedStringOperand(b); + RuntimeScalar aResolved = stringifyForStringContext(resolveTiedStringOperand(runtimeScalar)); + RuntimeScalar bResolved = stringifyForStringContext(resolveTiedStringOperand(b)); String bStr = bResolved.toString(); String aStr = aResolved.toString(); @@ -569,9 +569,9 @@ public static RuntimeScalar stringConcatWarnUninitialized(RuntimeScalar runtimeS // For tied variables, we must only FETCH once, then use the result for both // the definedness check and the actual concatenation. // First, resolve tied variables to get their actual values (triggers FETCH once per tied var) - RuntimeScalar aResolved = (runtimeScalar.type == RuntimeScalarType.TIED_SCALAR) + RuntimeScalar aResolved = (runtimeScalar.type == RuntimeScalarType.TIED_SCALAR) ? runtimeScalar.tiedFetch() : runtimeScalar; - RuntimeScalar bResolved = (b.type == RuntimeScalarType.TIED_SCALAR) + RuntimeScalar bResolved = (b.type == RuntimeScalarType.TIED_SCALAR) ? b.tiedFetch() : b; // Now check definedness on the resolved values (no additional FETCH) @@ -582,6 +582,9 @@ public static RuntimeScalar stringConcatWarnUninitialized(RuntimeScalar runtimeS RuntimeScalar overloaded = tryStringConcatOverload(aResolved, bResolved); if (overloaded != null) return overloaded; + + aResolved = stringifyForStringContext(aResolved); + bResolved = stringifyForStringContext(bResolved); // Get string values from resolved scalars String aStr = aResolved.toString(); @@ -838,7 +841,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa WarnDie.warnWithCategory(new RuntimeScalar("Use of uninitialized value in join or string"), RuntimeScalarCache.scalarEmptyString, "uninitialized"); } - RuntimeScalar resolved = resolveTiedStringOperand(scalar); + RuntimeScalar resolved = stringifyForStringContext(resolveTiedStringOperand(scalar)); RuntimeScalar res = new RuntimeScalar(resolved.toString()); if (resolved.type != RuntimeScalarType.STRING) { res.type = BYTE_STRING; @@ -853,7 +856,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa RuntimeScalarCache.scalarEmptyString, "uninitialized"); } - RuntimeScalar separatorResolved = resolveTiedStringOperand(runtimeScalar); + RuntimeScalar separatorResolved = stringifyForStringContext(resolveTiedStringOperand(runtimeScalar)); String delimiter = separatorResolved.toString(); // In Perl, join produces a byte-string unless one of the inputs has @@ -878,7 +881,7 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa RuntimeScalarCache.scalarEmptyString, "uninitialized"); } - RuntimeScalar resolved = resolveTiedStringOperand(scalar); + RuntimeScalar resolved = stringifyForStringContext(resolveTiedStringOperand(scalar)); if (resolved.type == RuntimeScalarType.STRING) { hasUtf8 = true; } @@ -893,6 +896,10 @@ private static RuntimeScalar joinInternal(RuntimeScalar runtimeScalar, RuntimeBa return recordJoinTaint(res); } + private static RuntimeScalar stringifyForStringContext(RuntimeScalar scalar) { + return RuntimeScalarType.blessedId(scalar) != 0 ? Overload.stringify(scalar) : scalar; + } + /** * Join for string interpolation - doesn't warn about undef values. * This is used internally by the compiler for string interpolation. diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 8da7676906..db05e348f7 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -70,6 +70,22 @@ my $sub_copy = $sub_target =~ s/(.+)/$sub_replacement/r; ok(!tainted($sub_target), 'non-destructive substitution leaves its source clean'); ok(tainted($sub_copy), 'non-destructive substitution preserves replacement taint'); +{ + package TaintStringify; + use overload '""' => sub { $_[0]->[0] }; + sub new { bless [$_[1]], $_[0] } +} + +my $tainted_object = TaintStringify->new("object$empty_taint"); +ok(tainted("$tainted_object"), 'stringification overload preserves result taint'); +ok(tainted("prefix$tainted_object"), 'mixed interpolation preserves overload result taint'); +$sub_target = 'abcd'; +$sub_target =~ s/(.+)/prefix$tainted_object/; +ok(tainted($sub_target), 'interpolated overloaded replacement preserves taint'); +$sub_target = 'abcd'; +$sub_copy = $sub_target =~ s/(.+)/$tainted_object/r; +ok(tainted($sub_copy), 'whole overloaded replacement preserves taint'); + { use re 'taint'; $sub_source = "abcd$empty_taint"; From fcb3d230fa3e74256c70bd855b8cbe3341ed27d3 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:16:20 +0200 Subject: [PATCH 09/26] feat: validate taint-sensitive process environment Reject empty, relative, and world-writable Unix PATH components before process launch, and reject tainted TERM values containing metacharacters. Keep direct tainted-variable diagnostics ahead of structural validation. Core op/taint.t improves from 1013 to 1028 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/SystemOperator.java | 50 +++++++++++++++++++ src/test/resources/unit/taint_mode.t | 13 +++++ 2 files changed, 63 insertions(+) diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index 690abc470d..6d0d96c96e 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -14,6 +14,10 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -36,6 +40,7 @@ public class SystemOperator { // Shell syntax that prevents Perl's one-string command direct-exec fast path. private static final Pattern DIRECT_COMMAND_SHELL_METACHARACTERS = Pattern.compile("[*?\\[\\]{}()<>|&;`'\"\\$%]"); + private static final Pattern TAINTED_ENV_METACHARACTERS = Pattern.compile("[^A-Za-z0-9_./-]"); private static String decodeSubprocessOutput(byte[] bytes) { StringBuilder decoded = new StringBuilder(bytes.length); @@ -275,6 +280,51 @@ private static void checkTaintEnvironment() { "Insecure $ENV{" + name + "} while running with -T switch"); } } + + RuntimeScalar path = env.elements.get("PATH"); + if (path != null && path.getDefinedBoolean()) { + checkPathDirectories(path.toString()); + } + + RuntimeScalar term = env.elements.get("TERM"); + if (term != null && term.isTainted() + && TAINTED_ENV_METACHARACTERS.matcher(term.toString()).find()) { + throw new PerlCompilerException( + "Insecure $ENV{TERM} while running with -T switch"); + } + } + + private static void checkPathDirectories(String pathValue) { + if (SystemUtils.osIsWindows()) { + return; + } + + for (String directory : pathValue.split(":", -1)) { + try { + Path path = Path.of(directory); + if (directory.isEmpty() || !path.isAbsolute() || isWorldWritableDirectory(path)) { + throw insecurePathException(); + } + } catch (InvalidPathException e) { + throw insecurePathException(); + } + } + } + + private static boolean isWorldWritableDirectory(Path path) { + if (!Files.isDirectory(path)) { + return false; + } + try { + return Files.getPosixFilePermissions(path).contains(PosixFilePermission.OTHERS_WRITE); + } catch (UnsupportedOperationException | IOException | SecurityException e) { + return false; + } + } + + private static PerlCompilerException insecurePathException() { + return new PerlCompilerException( + "Insecure directory in $ENV{PATH} while running with -T switch"); } private static List splitDirectCommandWords(String command) { diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index db05e348f7..e0898f723a 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -119,6 +119,19 @@ like($@, qr/^Insecure dependency in eval while running with -T switch/, ok(!$qx_ok, 'qx rejects a tainted command'); like($@, qr/^Insecure dependency in `` while running with -T switch/, 'qx reports the Perl security error'); + + local $ENV{PATH} = '.'; + my $path_ok = eval { qx{/bin/echo path-check}; 1 }; + ok(!$path_ok, 'process launch rejects a relative PATH directory'); + like($@, qr/Insecure directory in \$ENV\{PATH\}/, + 'relative PATH reports the Perl security error'); + + local $ENV{PATH} = '/usr/bin'; + local $ENV{TERM} = "unsafe=$empty_taint"; + my $term_ok = eval { qx{/bin/echo term-check}; 1 }; + ok(!$term_ok, 'process launch rejects tainted TERM metacharacters'); + like($@, qr/Insecure \$ENV\{TERM\}/, + 'tainted TERM reports the Perl security error'); } my $tainted_path = "/tmp/perlonjava-taint-no-such-$$" . $empty_taint; From 3b1e290596c27aa07fe67d068bfe75423c79dce7 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:22:19 +0200 Subject: [PATCH 10/26] fix: retain taint when materializing capture proxies Use complete RuntimeScalar copies when lazy regex special variables cross subroutine, block, and interpreter mutation boundaries. This preserves taint and the existing scalar metadata without making match taint sticky. Core op/taint.t improves from 1028 to 1029 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/BytecodeInterpreter.java | 7 +------ .../backend/bytecode/InlineOpcodeHandler.java | 7 +------ .../perlonjava/runtime/runtimetypes/RuntimeCode.java | 10 ++-------- src/test/resources/unit/taint_mode.t | 4 ++++ 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java index 95fd4bee32..4f075de9ed 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java +++ b/src/main/java/org/perlonjava/backend/bytecode/BytecodeInterpreter.java @@ -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; } diff --git a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java index 517e671f55..1bfebdcd9a 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java @@ -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; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index a1548dc664..e1504b3514 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -5014,10 +5014,7 @@ public static void materializeSpecialVarsInResult(RuntimeList result, int callCo RuntimeBase elem = elems.get(i); if (elem instanceof ScalarSpecialVariable ssv) { RuntimeScalar resolved = ssv.getValueAsScalar(); - RuntimeScalar concrete = new RuntimeScalar(); - concrete.type = resolved.type; - concrete.value = resolved.value; - elems.set(i, concrete); + elems.set(i, new RuntimeScalar(resolved)); } else if (!preserveAggregateLvalues && elem instanceof RuntimeArray arr) { // Copy array elements to ensure independence from local restoration. // For tied arrays, use getList() which dispatches through FETCHSIZE/FETCH, @@ -5066,10 +5063,7 @@ public static void materializeSpecialVarsInResult(RuntimeList result, int callCo public static RuntimeScalar materializeBlockResult(RuntimeScalar result) { if (result instanceof ScalarSpecialVariable ssv) { RuntimeScalar resolved = ssv.getValueAsScalar(); - RuntimeScalar concrete = new RuntimeScalar(); - concrete.type = resolved.type; - concrete.value = resolved.value; - return concrete; + return new RuntimeScalar(resolved); } return result; } diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index e0898f723a..434dd2ed89 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -40,6 +40,10 @@ ok(tainted(qr/$tainted_pattern/), 'qr preserves pattern taint'); use re 'taint'; $text =~ /^(.*)$/; ok(tainted($1), q{use re 'taint' preserves input taint in captures}); + + my $capture = sub { $_[0] =~ /^(.*)$/; $1 }; + ok(tainted($capture->($text)), 'capture returned from a sub preserves taint'); + ok(!tainted($capture->('clean')), 'capture taint does not stick to later matches'); } my $sub_source = "abcd$empty_taint"; From cba22633cf349dad7e324d146a71de56f69d5c71 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:50:12 +0200 Subject: [PATCH 11/26] feat: taint external runtime inputs at their source Mark subprocess output, symlink targets, selected passwd fields, program name, and file EOF undef values as external input while taint mode is active. Apply the same behavior in scalar and list contexts. Core op/taint.t improves from 1029 to 1037 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/nativ/ExtendedNativeUtils.java | 12 ++++++------ .../perlonjava/runtime/operators/Operator.java | 2 +- .../perlonjava/runtime/operators/Readline.java | 14 +++++++++----- .../runtime/operators/SystemOperator.java | 9 +++++---- .../runtime/runtimetypes/GlobalContext.java | 3 +++ src/test/resources/unit/taint_mode.t | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/nativ/ExtendedNativeUtils.java b/src/main/java/org/perlonjava/runtime/nativ/ExtendedNativeUtils.java index 0e32da315d..64a4e20097 100644 --- a/src/main/java/org/perlonjava/runtime/nativ/ExtendedNativeUtils.java +++ b/src/main/java/org/perlonjava/runtime/nativ/ExtendedNativeUtils.java @@ -55,28 +55,28 @@ private static RuntimeList passwdToList(FFMPosixInterface.PasswdEntry pw) { String shell = pw.shell(); long expire = pw.expire(); RuntimeArray.push(result, new RuntimeScalar(name)); - RuntimeArray.push(result, new RuntimeScalar(passwd)); + RuntimeArray.push(result, new RuntimeScalar(passwd).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar(uid)); RuntimeArray.push(result, new RuntimeScalar(gid)); RuntimeArray.push(result, new RuntimeScalar(change)); RuntimeArray.push(result, new RuntimeScalar("")); - RuntimeArray.push(result, new RuntimeScalar(gecos)); + RuntimeArray.push(result, new RuntimeScalar(gecos).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar(dir)); - RuntimeArray.push(result, new RuntimeScalar(shell)); + RuntimeArray.push(result, new RuntimeScalar(shell).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar(expire)); } else { String gecos = pw.gecos(); String dir = pw.dir(); String shell = pw.shell(); RuntimeArray.push(result, new RuntimeScalar(name)); - RuntimeArray.push(result, new RuntimeScalar(passwd)); + RuntimeArray.push(result, new RuntimeScalar(passwd).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar(uid)); RuntimeArray.push(result, new RuntimeScalar(gid)); RuntimeArray.push(result, new RuntimeScalar("")); RuntimeArray.push(result, new RuntimeScalar("")); - RuntimeArray.push(result, new RuntimeScalar(gecos)); + RuntimeArray.push(result, new RuntimeScalar(gecos).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar(dir)); - RuntimeArray.push(result, new RuntimeScalar(shell)); + RuntimeArray.push(result, new RuntimeScalar(shell).taintFromExternalInput()); RuntimeArray.push(result, new RuntimeScalar("")); } return result.getList(); diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index 1f4350dcef..fdcb20e5eb 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -946,7 +946,7 @@ public static RuntimeScalar readlink(int ctx, RuntimeBase... args) { if (Files.isSymbolicLink(linkPath)) { Path targetPath = Files.readSymbolicLink(linkPath); - return new RuntimeScalar(targetPath.toString()); + return new RuntimeScalar(targetPath.toString()).taintFromExternalInput(); } else { getGlobalVariable("main::!").set("Invalid argument"); return RuntimeScalar.undef(); diff --git a/src/main/java/org/perlonjava/runtime/operators/Readline.java b/src/main/java/org/perlonjava/runtime/operators/Readline.java index 7a0ddcc2f8..639ca37ce9 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Readline.java +++ b/src/main/java/org/perlonjava/runtime/operators/Readline.java @@ -84,7 +84,7 @@ public static RuntimeScalar readline(RuntimeIO runtimeIO) { // the file contents (possibly the empty string) even if the // handle is positioned at EOF; the next call returns undef. if (runtimeIO.eof().getBoolean() && runtimeIO.currentLineNumber > 0) { - return scalarUndef; + return externalUndef(); } StringBuilder content = new StringBuilder(); boolean isByteData = true; @@ -165,7 +165,7 @@ private static RuntimeScalar readParagraphMode(RuntimeIO runtimeIO) { // Return undef if we've reached EOF and no characters were read (excluding skipped newlines) if (!inParagraph && runtimeIO.eof().getBoolean()) { - return scalarUndef; + return externalUndef(); } // Increment the line number counter once per paragraph read. @@ -195,7 +195,7 @@ private static RuntimeScalar readFixedLength(RuntimeIO runtimeIO, int length) { // Return undef if we've reached EOF and no characters were read if (result.length() == 0 && runtimeIO.eof().getBoolean()) { - return scalarUndef; + return externalUndef(); } // Don't increment line numbers for fixed-length reads @@ -229,7 +229,7 @@ private static RuntimeScalar readUntilCharacter(RuntimeIO runtimeIO, char separa // Return undef if we've reached EOF and no characters were read if (line.isEmpty() && runtimeIO.eof().getBoolean()) { - return scalarUndef; + return externalUndef(); } RuntimeScalar result = new RuntimeScalar(line.toString()); @@ -269,7 +269,7 @@ private static RuntimeScalar readUntilString(RuntimeIO runtimeIO, String separat // Return undef if we've reached EOF and no characters were read if (line.isEmpty() && runtimeIO.eof().getBoolean()) { - return scalarUndef; + return externalUndef(); } RuntimeScalar result = new RuntimeScalar(line.toString()); @@ -279,6 +279,10 @@ private static RuntimeScalar readUntilString(RuntimeIO runtimeIO, String separat return result.taintFromExternalInput(); } + private static RuntimeScalar externalUndef() { + return new RuntimeScalar().taintFromExternalInput(); + } + /** * Reads a specified number of characters from a file handle into a scalar. * diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index 6d0d96c96e..3c33501928 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -967,21 +967,22 @@ private static RuntimeBase processOutput(String output, int ctx) { int separatorLength = separator.length(); if (separatorLength == 0) { - result.add(new RuntimeScalar(output)); + result.add(new RuntimeScalar(output).taintFromExternalInput()); } else { while (index < output.length()) { int nextIndex = output.indexOf(separator, index); if (nextIndex == -1) { - result.add(new RuntimeScalar(output.substring(index))); + result.add(new RuntimeScalar(output.substring(index)).taintFromExternalInput()); break; } - result.add(new RuntimeScalar(output.substring(index, nextIndex + separatorLength))); + result.add(new RuntimeScalar(output.substring(index, nextIndex + separatorLength)) + .taintFromExternalInput()); index = nextIndex + separatorLength; } } return list; } else { - return new RuntimeScalar(output); + return new RuntimeScalar(output).taintFromExternalInput(); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index bdca0ad749..a6736129d9 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -132,6 +132,9 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { if (!GlobalVariable.globalVariables.containsKey("main::0")) { GlobalVariable.getGlobalVariable("main::0").set(compilerOptions.fileName); } + if (compilerOptions.taintMode) { + GlobalVariable.getGlobalVariable("main::0").tainted = true; + } GlobalVariable.getGlobalVariable(GLOBAL_PHASE).set("RUN"); // ${^GLOBAL_PHASE} // ${^TAINT} - set to 1 if -T (taint mode) was specified, 0 otherwise // Only initialize if not already set (to avoid overwriting during re-initialization) diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 434dd2ed89..d07e16fd5d 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -131,6 +131,10 @@ like($@, qr/^Insecure dependency in eval while running with -T switch/, 'relative PATH reports the Perl security error'); local $ENV{PATH} = '/usr/bin'; + local $ENV{TERM} = 'dumb'; + my $command_output = qx{/bin/echo external-output}; + ok(tainted($command_output), 'command output enters Perl as tainted data'); + local $ENV{TERM} = "unsafe=$empty_taint"; my $term_ok = eval { qx{/bin/echo term-check}; 1 }; ok(!$term_ok, 'process launch rejects tainted TERM metacharacters'); @@ -138,6 +142,18 @@ like($@, qr/^Insecure dependency in eval while running with -T switch/, 'tainted TERM reports the Perl security error'); } +ok(tainted($0), 'the program name is tainted under taint mode'); + +{ + open my $source, '<', $^X or die $!; + local $/; + my $contents = <$source>; + my $eof = <$source>; + ok(tainted($contents), 'file input enters Perl as tainted data'); + ok(tainted($eof), 'undef returned at file EOF retains input provenance'); + close $source; +} + my $tainted_path = "/tmp/perlonjava-taint-no-such-$$" . $empty_taint; my @file_operations = ( [ unlink => sub { unlink $tainted_path } ], From 70c852c8ef2f410be634e3b8160d8e9ec3f4d1b2 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:54:56 +0200 Subject: [PATCH 12/26] fix: preserve taint on post-increment results Carry the source scalar's taint onto the old integer value returned by the post-increment fast path. The lvalue already retained taint; this aligns the returned pre-mutation value with the slower increment and decrement paths. Core op/taint.t improves from 1037 to 1038 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../perlonjava/runtime/runtimetypes/RuntimeScalar.java | 2 +- src/test/resources/unit/taint_mode.t | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 9d3a46534d..0829957737 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -3494,7 +3494,7 @@ public RuntimeScalar postAutoIncrement() { int intValue = (int) this.value; if (intValue < Integer.MAX_VALUE) { this.value = intValue + 1; - return new RuntimeScalar(intValue); // return old value directly + return new RuntimeScalar(intValue).propagateTaint(this); // return old value directly } } return postAutoIncrementLarge(); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index d07e16fd5d..476cf32a42 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -28,6 +28,14 @@ ok(!tainted(length('abc')), 'taint propagation does not contaminate cached clean ok(tainted(uc($text)), 'case conversion propagates taint'); ok(tainted(ord($text)), 'ord propagates taint'); +my $tainted_counter = 1 + (0 + $empty_taint); +my $old_counter = $tainted_counter++; +ok(tainted($old_counter), 'post-increment result preserves input taint'); +ok(tainted($tainted_counter), 'post-increment lvalue remains tainted'); +$old_counter = $tainted_counter--; +ok(tainted($old_counter), 'post-decrement result preserves input taint'); +ok(tainted($tainted_counter), 'post-decrement lvalue remains tainted'); + $text =~ /^(.*)$/; ok(!tainted($1), 'regex capture untaints validated input'); From a65a581461db134f6867a322276e0140c0455943 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 19:59:37 +0200 Subject: [PATCH 13/26] fix: return undef from failed sysopen Return Perl undef rather than a defined false scalar for sysopen failures, including ordinary open, create, O_EXCL, and O_NOFOLLOW failures. This keeps read-only tainted inputs permitted while preserving failure definedness. Core op/taint.t improves from 1038 to 1041 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../java/org/perlonjava/runtime/operators/IOOperator.java | 8 ++++---- src/test/resources/unit/taint_mode.t | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 4a7dd6c3c7..f6bb082268 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -1531,7 +1531,7 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { File file = RuntimeIO.resolveFile(fileName); if ((mode & O_NOFOLLOW) != 0 && Files.isSymbolicLink(file.toPath())) { getGlobalVariable("main::!").set("Too many levels of symbolic links"); - return scalarFalse; + return scalarUndef; } // Determine the base mode @@ -1559,7 +1559,7 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { // O_EXCL: "error if O_CREAT and the file already exists" if ((mode & O_EXCL) != 0 && existed) { getGlobalVariable("main::!").set("File exists"); - return scalarFalse; + return scalarUndef; } if (!existed) { try { @@ -1569,14 +1569,14 @@ public static RuntimeScalar sysopen(int ctx, RuntimeBase... args) { } catch (IOException e) { // Failed to create file getGlobalVariable("main::!").set(e.getMessage()); - return scalarFalse; + return scalarUndef; } } } RuntimeIO fh = RuntimeIO.open(fileName, modeStr); if (fh == null) { - return scalarFalse; + return scalarUndef; } // Set IO slot on the glob, following the same pattern as open() and socket() diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 476cf32a42..f576ed19e4 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -194,6 +194,8 @@ like($@, qr/^Insecure dependency in open while running with -T switch/, my $sysread_open_ok = eval { sysopen my $fh, $tainted_path, O_RDONLY; 1 }; ok($sysread_open_ok, 'sysopen permits a tainted read-only path'); +my $missing_sysopen = sysopen my $missing_fh, $tainted_path, O_RDONLY; +ok(!defined($missing_sysopen), 'failed read-only sysopen returns undef'); my $syswrite_open_ok = eval { sysopen my $fh, $tainted_path, O_WRONLY; 1 }; ok(!$syswrite_open_ok, 'sysopen rejects a tainted write path'); like($@, qr/^Insecure dependency in sysopen while running with -T switch/, From 1bae3bbcd0df562365fd618232164c44e093a5a0 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:06:03 +0200 Subject: [PATCH 14/26] feat: enforce taint checks for ioctl and fcntl Reject tainted command and data arguments before filehandle validation or native dispatch. Keep the security exceptions outside broad native fallback handlers so Perl receives the expected diagnostic in $@. Core op/taint.t improves from 1041 to 1043 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../org/perlonjava/runtime/operators/IOOperator.java | 6 ++++++ src/test/resources/unit/taint_mode.t | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index f6bb082268..49cccec793 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -2397,6 +2397,9 @@ public static RuntimeScalar fcntl(int ctx, RuntimeBase... args) { return scalarFalse; } + RuntimeScalar.checkTaint(args[1].scalar(), "fcntl"); + RuntimeScalar.checkTaint(args[2].scalar(), "fcntl"); + try { RuntimeScalar fileHandle = args[0].scalar(); int function = args[1].scalar().getInt(); @@ -2470,6 +2473,9 @@ public static RuntimeScalar ioctl(int ctx, RuntimeBase... args) { return scalarFalse; } + RuntimeScalar.checkTaint(args[1].scalar(), "ioctl"); + RuntimeScalar.checkTaint(args[2].scalar(), "ioctl"); + try { RuntimeScalar fileHandle = args[0].scalar(); long request = args[1].scalar().getLong(); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index f576ed19e4..c0fb2f51a2 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -152,6 +152,15 @@ like($@, qr/^Insecure dependency in eval while running with -T switch/, ok(tainted($0), 'the program name is tainted under taint mode'); +{ + open my $ioctl_fh, '<', $^X or die $!; + my $ioctl_ok = eval { ioctl $ioctl_fh, 0 + $empty_taint, "x$empty_taint"; 1 }; + ok(!$ioctl_ok, 'ioctl rejects tainted control arguments'); + like($@, qr/^Insecure dependency in ioctl while running with -T switch/, + 'ioctl reports the Perl security error'); + close $ioctl_fh; +} + { open my $source, '<', $^X or die $!; local $/; From 4dc559215cbc102cbace86d9f325f8768425a951 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:11:34 +0200 Subject: [PATCH 15/26] feat: propagate taint into the formline accumulator Preserve existing $^A taint and combine it with formline picture and value taint when appending formatted output. Snapshot provenance before scalar set operations clear metadata. Core op/taint.t improves from 1043 to 1045 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../org/perlonjava/runtime/operators/IOOperator.java | 10 +++++++++- src/test/resources/unit/taint_mode.t | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 49cccec793..620136a80c 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -1807,15 +1807,18 @@ public static RuntimeScalar formline(int ctx, RuntimeBase... args) { } // Get the format template - String formatTemplate = args[0].scalar().toString(); + RuntimeScalar picture = args[0].scalar(); + String formatTemplate = picture.toString(); // For simple cases (like constants in index.t), if there are no format fields, // just append the template string directly to $^A if (!formatTemplate.contains("@") && !formatTemplate.contains("^")) { // Simple case: no format fields, just append the string RuntimeScalar accumulator = getGlobalVariable(GlobalContext.encodeSpecialVar("A")); + boolean resultTainted = accumulator.isTainted() || picture.isTainted(); String currentValue = accumulator.toString(); accumulator.set(currentValue + formatTemplate); + accumulator.tainted = resultTainted; return scalarTrue; } @@ -1842,8 +1845,13 @@ public static RuntimeScalar formline(int ctx, RuntimeBase... args) { // Append to $^A RuntimeScalar accumulator = getGlobalVariable(GlobalContext.encodeSpecialVar("A")); + boolean resultTainted = accumulator.isTainted() || picture.isTainted(); + for (int i = 1; i < args.length; i++) { + resultTainted |= args[i].scalar().isTainted(); + } String currentValue = accumulator.toString(); accumulator.set(currentValue + formattedOutput); + accumulator.tainted = resultTainted; // Return success (1) return scalarTrue; diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index c0fb2f51a2..8d59ed382a 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -152,6 +152,18 @@ like($@, qr/^Insecure dependency in eval while running with -T switch/, ok(tainted($0), 'the program name is tainted under taint mode'); +{ + local $^A = ''; + formline '@<<<<', $text; + ok(tainted($^A), 'tainted formline argument taints the accumulator'); + $^A = ''; + formline '@<<<<' . $empty_taint, 'clean'; + ok(tainted($^A), 'tainted formline picture taints the accumulator'); + $^A = $empty_taint; + formline '@<<<<', 'clean'; + ok(tainted($^A), 'formline preserves existing accumulator taint'); +} + { open my $ioctl_fh, '<', $^X or die $!; my $ioctl_ok = eval { ioctl $ioctl_fh, 0 + $empty_taint, "x$empty_taint"; 1 }; From c9920559e918a74b7035547975a8f9a311247b87 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:22:22 +0200 Subject: [PATCH 16/26] fix: retain tainted repeat provenance for formline pictures Track tainted repeat-count provenance without tainting a bare repetition, expose it when the value is composed into a dynamic picture, and consume it in formline. Copy, localize, and clear the internal metadata with scalar state. Core op/taint.t improves from 1045 to 1047 passing assertions. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/IOOperator.java | 6 +++-- .../runtime/operators/Operator.java | 1 + .../runtime/operators/StringOperators.java | 5 +++- .../runtime/runtimetypes/RuntimeScalar.java | 26 +++++++++++++++++++ src/test/resources/unit/taint_mode.t | 6 +++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index 620136a80c..d794ee3e24 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -1815,7 +1815,8 @@ public static RuntimeScalar formline(int ctx, RuntimeBase... args) { if (!formatTemplate.contains("@") && !formatTemplate.contains("^")) { // Simple case: no format fields, just append the string RuntimeScalar accumulator = getGlobalVariable(GlobalContext.encodeSpecialVar("A")); - boolean resultTainted = accumulator.isTainted() || picture.isTainted(); + boolean resultTainted = accumulator.isTainted() || picture.isTainted() + || picture.formatPictureTainted; String currentValue = accumulator.toString(); accumulator.set(currentValue + formatTemplate); accumulator.tainted = resultTainted; @@ -1845,7 +1846,8 @@ public static RuntimeScalar formline(int ctx, RuntimeBase... args) { // Append to $^A RuntimeScalar accumulator = getGlobalVariable(GlobalContext.encodeSpecialVar("A")); - boolean resultTainted = accumulator.isTainted() || picture.isTainted(); + boolean resultTainted = accumulator.isTainted() || picture.isTainted() + || picture.formatPictureTainted; for (int i = 1; i < args.length; i++) { resultTainted |= args[i].scalar().isTainted(); } diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index fdcb20e5eb..7510795aae 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -811,6 +811,7 @@ public static RuntimeBase repeat(RuntimeBase value, RuntimeScalar timesScalar, i scalarValue = value.scalar(); } RuntimeScalar rv = new RuntimeScalar(scalarValue.toString().repeat(Math.max(0, times))); + rv.formatPictureTainted = timesScalar.isTainted(); if (scalarValue.type == RuntimeScalarType.BYTE_STRING) { rv.type = RuntimeScalarType.BYTE_STRING; } diff --git a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java index 5f82ab5cf0..4fa29732b7 100644 --- a/src/main/java/org/perlonjava/runtime/operators/StringOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/StringOperators.java @@ -547,9 +547,12 @@ private static RuntimeScalar resolveTiedStringOperand(RuntimeScalar scalar) { private static RuntimeScalar propagateTaint(RuntimeScalar result, RuntimeScalar... inputs) { for (RuntimeScalar input : inputs) { + if (input != null && input.formatPictureTainted) { + result.formatPictureTainted = true; + result.tainted = true; + } if (input != null && input.isTainted()) { result.tainted = true; - break; } } return result; diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 0829957737..cedd5a09fe 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -111,6 +111,9 @@ private static boolean mightBeInteger(String s) { /** True for the non-reference scalar produced by dereferencing a qr// value. */ public boolean firstClassRegexScalar; + /** Internal provenance used by formline for a picture built with a tainted repeat count. */ + public boolean formatPictureTainted; + /** * True once a string scalar has been used in numeric context. Perl keeps a * numeric slot alongside the string slot on the same SV; this lightweight @@ -393,6 +396,7 @@ public RuntimeScalar(RuntimeScalar scalar) { this.numericLiteralText = scalar.numericLiteralText; this.numericContextSeen = scalar.numericContextSeen; this.firstClassRegexScalar = scalar.firstClassRegexScalar; + this.formatPictureTainted = scalar.formatPictureTainted; if (this.type == GLOBREFERENCE && this.value instanceof RuntimeGlob glob && glob.globName == null) { glob.ioHolderCount++; @@ -473,6 +477,7 @@ public RuntimeScalar(Object value) { this.numericLiteralText = scalar.numericLiteralText; this.numericContextSeen = scalar.numericContextSeen; this.firstClassRegexScalar = scalar.firstClassRegexScalar; + this.formatPictureTainted = scalar.formatPictureTainted; } case Long longValue -> initializeWithLong(longValue); default -> { @@ -581,6 +586,7 @@ private void initializeWithLong(Long value) { this.tainted = false; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) { // Java double can only exactly represent integers up to 2^53. // Beyond that, storing as DOUBLE loses precision and breaks exact pack/unpack @@ -1359,6 +1365,7 @@ public RuntimeScalar set(RuntimeScalar value) { this.numericLiteralText = value.numericLiteralText; this.numericContextSeen = value.numericContextSeen; this.firstClassRegexScalar = value.firstClassRegexScalar; + this.formatPictureTainted = value.formatPictureTainted; RuntimePosLvalue.invalidatePos(this); } else { this.type = value.type; @@ -1368,6 +1375,7 @@ public RuntimeScalar set(RuntimeScalar value) { this.numericLiteralText = value.numericLiteralText; this.numericContextSeen = value.numericContextSeen; this.firstClassRegexScalar = value.firstClassRegexScalar; + this.formatPictureTainted = value.formatPictureTainted; } refreshSubstrLvalues(); return this; @@ -1435,6 +1443,7 @@ private RuntimeScalar setLarge(RuntimeScalar value) { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } // Unwrap source special types via switch dispatcher @@ -1476,6 +1485,7 @@ private RuntimeScalar setLarge(RuntimeScalar value) { this.numericLiteralText = value.numericLiteralText; this.numericContextSeen = value.numericContextSeen; this.firstClassRegexScalar = value.firstClassRegexScalar; + this.formatPictureTainted = value.formatPictureTainted; return this; } @@ -1640,6 +1650,7 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) { this.numericLiteralText = value.numericLiteralText; this.numericContextSeen = value.numericContextSeen; this.firstClassRegexScalar = value.firstClassRegexScalar; + this.formatPictureTainted = value.formatPictureTainted; if (this.globalCodeRefFqn != null && this.value instanceof RuntimeCode code) { code.hadStashRef = true; } @@ -1889,6 +1900,7 @@ public RuntimeScalar set(int value) { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } @@ -1904,6 +1916,7 @@ public RuntimeScalar set(long value) { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } @@ -1944,6 +1957,7 @@ else if (value.abs().compareTo(BigInteger.valueOf(9007199254740992L)) <= 0) { // this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } @@ -1960,6 +1974,7 @@ public RuntimeScalar set(boolean value) { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } @@ -1981,6 +1996,7 @@ public RuntimeScalar set(String value) { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; return this; } @@ -2488,6 +2504,7 @@ public RuntimeScalar scalarDeref() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; yield newScalar; } case REFERENCE -> (RuntimeScalar) value; @@ -2993,6 +3010,7 @@ public RuntimeScalar undefine() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Invalidate the method resolution cache InheritanceResolver.invalidateCache(); if (releasedCode && WeakRefRegistry.weakRefsExist && !ModuleInitGuard.inModuleInit()) { @@ -3022,6 +3040,7 @@ public RuntimeScalar undefine() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Decrement AFTER clearing (Perl 5 semantics: DESTROY sees the new state) boolean undefOnBlessedWithDestroy = false; @@ -3387,6 +3406,7 @@ public RuntimeScalar preAutoIncrement() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Cases 0-11 are listed in order from RuntimeScalarType, and compile to fast tableswitch switch (type) { case INTEGER -> { // 0 @@ -3508,6 +3528,7 @@ private RuntimeScalar postAutoIncrementLarge() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Cases 0-11 are listed in order from RuntimeScalarType, and compile to fast tableswitch switch (type) { @@ -3610,6 +3631,7 @@ public RuntimeScalar preAutoDecrement() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Cases 0-11 are listed in order from RuntimeScalarType, and compile to fast tableswitch switch (type) { case INTEGER -> // 0 @@ -3715,6 +3737,7 @@ public RuntimeScalar postAutoDecrement() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; // Cases 0-11 are listed in order from RuntimeScalarType, and compile to fast tableswitch switch (type) { @@ -3864,6 +3887,7 @@ public void dynamicSaveState() { currentState.numericLiteralText = this.numericLiteralText; currentState.numericContextSeen = this.numericContextSeen; currentState.firstClassRegexScalar = this.firstClassRegexScalar; + currentState.formatPictureTainted = this.formatPictureTainted; // Push the current state onto the stack dynamicStateStack.push(currentState); // Clear the current type and value @@ -3875,6 +3899,7 @@ public void dynamicSaveState() { this.numericLiteralText = null; this.numericContextSeen = false; this.firstClassRegexScalar = false; + this.formatPictureTainted = false; } /** @@ -3908,6 +3933,7 @@ public void dynamicRestoreState() { this.numericLiteralText = previousState.numericLiteralText; this.numericContextSeen = previousState.numericContextSeen; this.firstClassRegexScalar = previousState.firstClassRegexScalar; + this.formatPictureTainted = previousState.formatPictureTainted; releaseScalarReferenceContents(scalarReferenceContents); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 8d59ed382a..4ec72b1c7e 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -162,6 +162,12 @@ ok(tainted($0), 'the program name is tainted under taint mode'); $^A = $empty_taint; formline '@<<<<', 'clean'; ok(tainted($^A), 'formline preserves existing accumulator taint'); + my $tainted_width = 5 + (0 + $empty_taint); + my $dynamic_picture = '@' . ('<' x $tainted_width); + ok(tainted($dynamic_picture), 'dynamic picture composition exposes repeat-count taint'); + $^A = ''; + formline $dynamic_picture, 'clean'; + ok(tainted($^A), 'tainted repeat count marks a dynamic formline picture'); } { From 0b180193a0e90baa4bafcdfab145186afde91748 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:29:55 +0200 Subject: [PATCH 17/26] fix: preserve taint on tied container keys Keep runtime scalar keys intact through bytecode array and tied hash assignment paths so STORE receives the same taint provenance as Perl. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/InlineOpcodeHandler.java | 18 ++++++++----- src/test/resources/unit/taint_mode.t | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java index 1bfebdcd9a..e40989c615 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java @@ -536,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; @@ -565,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; @@ -736,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; } diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 4ec72b1c7e..26e7c17158 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -307,4 +307,29 @@ ok(!$printf_ok, 'printf rejects a tainted format'); like($@, qr/^Insecure dependency in printf while running with -T switch/, 'printf reports the Perl security error'); +{ + package TaintStoreProbe; + our @seen; + sub TIEARRAY { bless {}, shift } + sub TIEHASH { bless {}, shift } + sub STORE { + push @seen, [ + Scalar::Util::tainted($_[1]), + Scalar::Util::tainted($_[2]), + ]; + } + + package main; + my $key = "1$empty_taint"; + my $value = "value$empty_taint"; + tie my @tied_array, 'TaintStoreProbe'; + tie my %tied_hash, 'TaintStoreProbe'; + $tied_array[$key] = $value; + $tied_hash{$key} = $value; + ok($TaintStoreProbe::seen[0][0] && $TaintStoreProbe::seen[0][1], + 'tied array STORE receives tainted key and value'); + ok($TaintStoreProbe::seen[1][0] && $TaintStoreProbe::seen[1][1], + 'tied hash STORE receives tainted key and value'); +} + done_testing; From 683d2a20f1e35629446074423a54b2373fc54214 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:34:07 +0200 Subject: [PATCH 18/26] fix: propagate dynamic method taint to AUTOLOAD Preserve the requested method scalar through method resolution and apply its taint provenance to the fully qualified AUTOLOAD variable value. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/runtimetypes/RuntimeCode.java | 5 +++-- src/test/resources/unit/taint_mode.t | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index e1504b3514..e64595c90c 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -1197,7 +1197,7 @@ private static String autoloadVarFor(RuntimeScalar autoloadCoderef, String looku */ private static void setAutoloadMethodName( String variableName, String fullMethodName, RuntimeScalar methodName) { - RuntimeScalar value = new RuntimeScalar(fullMethodName); + RuntimeScalar value = new RuntimeScalar(fullMethodName).propagateTaint(methodName); if (methodName.type == RuntimeScalarType.BYTE_STRING) { value.type = RuntimeScalarType.BYTE_STRING; } @@ -3191,6 +3191,7 @@ private static RuntimeList dispatchPerlMethodAfterSelfInjected( } String methodName = method.toString(); + RuntimeScalar requestedMethod = method; // Unwrap READONLY_SCALAR for method dispatch. // Constants created via `use constant` with blessed refs go through @@ -3354,7 +3355,7 @@ private static RuntimeList dispatchPerlMethodAfterSelfInjected( String fullMethodName = qualifyAutoloadMethodName(methodName, perlClassName); // Set the $AUTOLOAD variable to the fully qualified name of the method - setAutoloadMethodName(autoloadVariableName, fullMethodName, method); + setAutoloadMethodName(autoloadVariableName, fullMethodName, requestedMethod); } return apply(method, args, callContext); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 26e7c17158..52cdc6a582 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -332,4 +332,24 @@ like($@, qr/^Insecure dependency in printf while running with -T switch/, 'tied hash STORE receives tainted key and value'); } +{ + package TaintAutoloadProbe; + our @seen; + sub AUTOLOAD { + our $AUTOLOAD; + return if $AUTOLOAD =~ /DESTROY/; + push @seen, Scalar::Util::tainted($AUTOLOAD); + } + + package main; + my $object = bless {}, 'TaintAutoloadProbe'; + my $tainted_method = "tainted_method$empty_taint"; + $object->$tainted_method; + $object->clean_method; + ok($TaintAutoloadProbe::seen[0], + 'AUTOLOAD name inherits taint from a dynamic method name'); + ok(!$TaintAutoloadProbe::seen[1], + 'AUTOLOAD name is reset to clean for a clean method name'); +} + done_testing; From d4104d9078be5e1f6ebda5e6434a6e16c4c6e383 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:37:36 +0200 Subject: [PATCH 19/26] fix: preserve taint when dereferencing regex values Transfer taint provenance from qr values to their bare-regex scalar form so substitution and matching retain the compiled pattern's security metadata. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../org/perlonjava/runtime/runtimetypes/RuntimeScalar.java | 2 +- src/test/resources/unit/taint_mode.t | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index cedd5a09fe..6b0e8a692b 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -2516,7 +2516,7 @@ public RuntimeScalar scalarDeref() { result.type = RuntimeScalarType.STRING; result.value = this.value.toString(); result.firstClassRegexScalar = true; - yield result; + yield result.propagateTaint(this); } case GLOB -> { // Dereferencing a glob as scalar returns the scalar slot diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 52cdc6a582..ddce78d9b5 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -43,6 +43,13 @@ my $tainted_pattern = "(abc)$empty_taint"; 'abc' =~ /$tainted_pattern/; ok(tainted($1), 'a tainted regex pattern taints its capture'); ok(tainted(qr/$tainted_pattern/), 'qr preserves pattern taint'); +my $tainted_regex_ref = qr/(.)$empty_taint/; +my $bare_tainted_regex = $$tainted_regex_ref; +my $bare_regex_target = 'abc'; +$bare_regex_target =~ s/$bare_tainted_regex/x/; +ok(tainted($bare_tainted_regex), 'dereferencing qr preserves regex taint'); +ok(tainted($bare_regex_target), + 'substitution with a dereferenced tainted regex taints the target'); { use re 'taint'; From 0223689a32fa8bdf693dbcbb6aaf5dd810f8c14a Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:44:47 +0200 Subject: [PATCH 20/26] feat: reject insecure tainted regex constructs Reject tainted interpolated user-defined Unicode properties and runtime eval groups before regex cache lookup, matching Perl taint-mode security. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/regex/RuntimeRegex.java | 30 +++++++++++++++++++ src/test/resources/unit/taint_mode.t | 16 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index c333fb88fb..c637332a12 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -38,6 +38,8 @@ public class RuntimeRegex extends RuntimeBase implements RuntimeScalarReference private static final int CASE_INSENSITIVE = Pattern.CASE_INSENSITIVE; private static final int MULTILINE = Pattern.MULTILINE; private static final int DOTALL = Pattern.DOTALL; + private static final Pattern USER_DEFINED_PROPERTY_PATTERN = + Pattern.compile("\\\\([pP])\\{((?:[A-Za-z_][A-Za-z0-9_]*::)*(?:[Ii][sS]|[Ii][nN])[A-Za-z0-9_]*)}"); // Maximum size for the regex cache private static final int MAX_REGEX_CACHE_SIZE = 1000; // Cache to store compiled regex patterns @@ -873,6 +875,8 @@ public static RuntimeScalar getQuotedRegex(RuntimeScalar patternString, RuntimeS // Unwrap readonly scalar if (patternString.type == RuntimeScalarType.READONLY_SCALAR) patternString = (RuntimeScalar) patternString.value; + validateTaintedPatternSecurity(patternString); + // Check if patternString is already a compiled regex if (patternString.type == RuntimeScalarType.REGEX) { RuntimeRegex originalRegex = (RuntimeRegex) patternString.value; @@ -948,6 +952,32 @@ public static RuntimeScalar getQuotedRegex(RuntimeScalar patternString, RuntimeS .propagateTaint(patternString); } + private static void validateTaintedPatternSecurity(RuntimeScalar patternString) { + if (!GlobalContext.isTaintModeActive() || patternString == null + || !patternString.isTainted() || patternString.type == RuntimeScalarType.REGEX) { + return; + } + + String pattern = patternString.toString(); + if (pattern.contains("(?{") || pattern.contains("(??{")) { + throw new PerlCompilerException("Eval-group in insecure regular expression"); + } + + Matcher matcher = USER_DEFINED_PROPERTY_PATTERN.matcher(pattern); + if (!matcher.find()) { + return; + } + + String property = matcher.group(2); + String qualified = property.contains("::") ? property : "main::" + property; + if (GlobalVariable.isGlobalCodeRefDefined(qualified)) { + throw new PerlCompilerException( + "Insecure user-defined property \"" + property + "\" in regex"); + } + throw new PerlCompilerException( + "Insecure user-defined property \\" + matcher.group(1) + "{" + qualified + "}"); + } + /** * Variant of getQuotedRegex that supports the /o modifier. * When callsiteId is provided and modifiers contain 'o', the regex is compiled only once diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index ddce78d9b5..281cf8b5cf 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -359,4 +359,20 @@ like($@, qr/^Insecure dependency in printf while running with -T switch/, 'AUTOLOAD name is reset to clean for a clean method name'); } +sub IsTaintProbeA { '0041' } +my $tainted_property = "IsTaintProbeA$empty_taint"; +my $property_ok = eval { 'A' =~ /\p{$tainted_property}/; 1 }; +ok(!$property_ok, 'tainted user-defined regex property is rejected'); +like($@, qr/^Insecure user-defined property "IsTaintProbeA" in regex/, + 'tainted user-defined property reports the Perl security error'); + +{ + use re 'eval'; + my $tainted_code_pattern = "(?{})$empty_taint"; + my $code_pattern_ok = eval { 'a' =~ /$tainted_code_pattern/; 1 }; + ok(!$code_pattern_ok, 'tainted runtime regex code block is rejected'); + like($@, qr/^Eval-group in insecure regular expression/, + 'tainted regex code block reports the Perl security error'); +} + done_testing; From ada6cb1c312baab05fc8a6ae5d5e2172a5403bad Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:47:49 +0200 Subject: [PATCH 21/26] fix: smartmatch tainted scalars against arrays Implement scalar-versus-array smartmatch candidate traversal without consuming or replacing the tainted left operand between comparisons. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/CompareOperators.java | 14 ++++++++++++++ src/test/resources/unit/taint_mode.t | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/org/perlonjava/runtime/operators/CompareOperators.java b/src/main/java/org/perlonjava/runtime/operators/CompareOperators.java index ebdc9aa20e..fd0f50fcfb 100644 --- a/src/main/java/org/perlonjava/runtime/operators/CompareOperators.java +++ b/src/main/java/org/perlonjava/runtime/operators/CompareOperators.java @@ -706,6 +706,20 @@ public static RuntimeScalar smartmatch(RuntimeScalar arg1, RuntimeScalar arg2) { if (result != null) return result; } + // Scalar ~~ ARRAY matches when the scalar smartmatches any array + // element. Keep the original scalar intact across candidates: tainted + // strings must not have their backing value consumed by a failed + // comparison before a later element matches. + if (arg2.type == RuntimeScalarType.ARRAYREFERENCE + && arg2.value instanceof RuntimeArray candidates) { + for (RuntimeScalar candidate : candidates) { + if (smartmatch(arg1, candidate).getBoolean()) { + return scalarTrue; + } + } + return scalarFalse; + } + // Check if both are defined if (!arg1.getDefinedBoolean() && !arg2.getDefinedBoolean()) { return scalarTrue; // undef ~~ undef is true diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 281cf8b5cf..fc876f6345 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -290,6 +290,10 @@ ok(tainted($split[0]) && tainted($split[1]), 'split propagates input taint'); no warnings 'numeric'; ok(tainted(~("abc$empty_taint")), 'bitwise complement propagates taint'); } +ok("M$empty_taint" ~~ ['m', 'M'], + 'tainted scalar smartmatches a later array element'); +ok(!("M$empty_taint" ~~ ['m', undef]), + 'tainted scalar smartmatch handles undef array elements'); ok(tainted(crypt('secret', "aa$empty_taint")), 'crypt propagates salt taint'); ok(tainted(vec("A$empty_taint", 0, 8)), 'vec propagates source taint'); ok(tainted(pack('a*', "packed$empty_taint")), 'pack propagates value taint'); From fb4ac854fa19c3a572a97aef4a1ba206fe3d7f60 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:53:08 +0200 Subject: [PATCH 22/26] fix: retain indirect command operands in bytecode Materialize system and exec indirect program expressions with their argument lists so interpreter taint checks inspect every process command component. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../backend/bytecode/CompileOperator.java | 14 +++++++++++++- src/test/resources/unit/taint_mode.t | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java index 2ef9866988..82f2684f10 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java @@ -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); diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index fc876f6345..fe36b163bb 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -318,6 +318,22 @@ ok(!$printf_ok, 'printf rejects a tainted format'); like($@, qr/^Insecure dependency in printf while running with -T switch/, 'printf reports the Perl security error'); +{ + local $ENV{PATH} = '/usr/bin'; + for my $case ( + [ 'system PROGRAM LIST without comma', + sub { system $empty_taint 'clean-argument' } ], + [ 'system { PROGRAM } LIST', + sub { system { 'clean-program' } $empty_taint } ], + ) { + my ($description, $operation) = @$case; + my $ok = eval { $operation->(); 1 }; + ok(!$ok, "$description rejects tainted command data"); + like($@, qr/^Insecure dependency in system while running with -T switch/, + "$description reports the Perl security error"); + } +} + { package TaintStoreProbe; our @seen; From 84ab76c73878d3030056dfee982e79cfa3358b16 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 20:58:26 +0200 Subject: [PATCH 23/26] feat: reject tainted assignments to $^O Represent $^O with a protected runtime scalar and preserve that special type through local scopes so tainted assignments fail without tainting reads. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/runtimetypes/GlobalContext.java | 4 +++- .../runtime/runtimetypes/GlobalRuntimeScalar.java | 3 +++ .../runtimetypes/OperatingSystemVariable.java | 14 ++++++++++++++ src/test/resources/unit/taint_mode.t | 8 ++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/perlonjava/runtime/runtimetypes/OperatingSystemVariable.java diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java index a6736129d9..d3b4c935b0 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalContext.java @@ -79,7 +79,9 @@ public static void initializeGlobals(CompilerOptions compilerOptions) { // $^S - current state of the interpreter (undef=compiling, 0=not in eval, 1=in eval) GlobalVariable.globalVariables.put("main::" + Character.toString('S' - 'A' + 1), new ScalarSpecialVariable(ScalarSpecialVariable.Id.EVAL_STATE)); - GlobalVariable.getGlobalVariable("main::" + Character.toString('O' - 'A' + 1)).set(SystemUtils.getPerlOsName()); // initialize $^O + GlobalVariable.globalVariables.put( + "main::" + Character.toString('O' - 'A' + 1), + new OperatingSystemVariable(SystemUtils.getPerlOsName())); // initialize $^O GlobalVariable.getGlobalVariable("main::" + Character.toString('V' - 'A' + 1)).set(Configuration.getPerlVersionVString()); // initialize $^V GlobalVariable.getGlobalVariable("main::" + Character.toString('T' - 'A' + 1)).set((int) (System.currentTimeMillis() / 1000)); // initialize $^T to epoch time // Initialize $^W based on -w flag diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java index 292df9e7e7..96827d00b1 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/GlobalRuntimeScalar.java @@ -85,6 +85,9 @@ public void dynamicSaveState() { OutputFieldSeparator.saveInternalOFS(); newLocal = new OutputFieldSeparator(); newLocal.set(RuntimeScalarCache.scalarUndef); + } else if (originalVariable instanceof OperatingSystemVariable) { + newLocal = new OperatingSystemVariable(""); + newLocal.set(RuntimeScalarCache.scalarUndef); } else { newLocal = new GlobalRuntimeScalar(fullName); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/OperatingSystemVariable.java b/src/main/java/org/perlonjava/runtime/runtimetypes/OperatingSystemVariable.java new file mode 100644 index 0000000000..10bc5b7322 --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/OperatingSystemVariable.java @@ -0,0 +1,14 @@ +package org.perlonjava.runtime.runtimetypes; + +/** Perl's $^O: mutable for compatibility, but rejects tainted assignments. */ +public final class OperatingSystemVariable extends RuntimeScalar { + public OperatingSystemVariable(String value) { + super(value); + } + + @Override + public RuntimeScalar set(RuntimeScalar value) { + RuntimeScalar.checkTaint(value, "assigning to $^O"); + return super.set(value); + } +} diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index fe36b163bb..f5808837f2 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -13,6 +13,14 @@ my $text = "abc$empty_taint"; ok(${^TAINT}, '-T enables ${^TAINT}'); ok(tainted($^X), '$^X is tainted'); ok(tainted($ENV{PATH}), '%ENV values are tainted'); +ok(!tainted($^O), '$^O is not tainted'); +{ + local $^O; + my $os_assignment_ok = eval { $^O = $^X; 1 }; + ok(!$os_assignment_ok, '$^O rejects a tainted assignment'); + like($@, qr/^Insecure dependency in assigning to \$\^O while running with -T switch/, + '$^O assignment reports the Perl security error'); +} ok(tainted($empty_taint), 'substr preserves source taint'); ok(tainted($text), 'concatenation propagates taint'); From 97dbed49eea18cfa9124a0524b9827ea26f82228 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 21:01:54 +0200 Subject: [PATCH 24/26] feat: reject process execution with aliased %ENV Track whether %ENV was installed from a hash reference or named typeglob and report Perl-compatible alias diagnostics before other environment checks. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/SystemOperator.java | 5 +++++ .../runtime/runtimetypes/RuntimeGlob.java | 8 ++++++++ .../runtime/runtimetypes/RuntimeHash.java | 3 +++ src/test/resources/unit/taint_mode.t | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index 3c33501928..1255b8af07 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -273,6 +273,11 @@ private static void checkTaintEnvironment() { return; } RuntimeHash env = GlobalVariable.getGlobalHash("main::ENV"); + if (env.taintEnvironmentAliasDescription != null) { + throw new PerlCompilerException( + "%ENV is aliased to " + env.taintEnvironmentAliasDescription + + " while running with -T switch"); + } for (String name : List.of("PATH", "IFS", "CDPATH", "ENV", "BASH_ENV")) { RuntimeScalar value = env.elements.get(name); if (value != null && value.isTainted()) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java index 209bbbd87d..eda04ff303 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java @@ -410,6 +410,9 @@ public RuntimeScalar set(RuntimeScalar value) { // `*foo = \%bar` creates an alias - both names refer to the same hash // Also update all glob aliases if (value.value instanceof RuntimeHash hash) { + if ("main::ENV".equals(this.globName) || "ENV".equals(this.globName)) { + hash.taintEnvironmentAliasDescription = "another variable"; + } GlobalVariable.markPackageGlobalRoot(hash); for (String aliasedName : GlobalVariable.getGlobAliasGroup(this.globName)) { GlobalVariable.globalHashes.put(aliasedName, hash); @@ -654,6 +657,11 @@ public RuntimeScalar set(RuntimeGlob value) { || globName.endsWith("::"); if (sourceHasHash) { RuntimeHash sourceHash = GlobalVariable.getGlobalHash(globName); + if ("main::ENV".equals(this.globName) || "ENV".equals(this.globName)) { + String sourceName = globName.startsWith("main::") + ? globName.substring(6) : globName; + sourceHash.taintEnvironmentAliasDescription = "%" + sourceName; + } GlobalVariable.markPackageGlobalRoot(sourceHash); GlobalVariable.globalHashes.put(this.globName, sourceHash); GlobalVariable.invalidatePackageRootSnapshot(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java index d139020b53..21a45a294e 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java @@ -32,6 +32,9 @@ public class RuntimeHash extends RuntimeBase implements RuntimeScalarReference, public int type; // Map to store the elements of the hash public Map elements; + // Set when this hash is installed as %ENV through a typeglob alias. + // Perl rejects process execution before inspecting PATH in that case. + public String taintEnvironmentAliasDescription; // Iterator for traversing the hash elements Iterator hashIterator; // Track which keys were stored with BYTE_STRING type (vs STRING/UTF-8). diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index f5808837f2..024058eee2 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -342,6 +342,24 @@ like($@, qr/^Insecure dependency in printf while running with -T switch/, } } +{ + our %taint_env_alias = (PATH => '/usr/bin'); + { + local *ENV = \%taint_env_alias; + my $ok = eval { system 'taint-command-that-does-not-exist'; 1 }; + ok(!$ok, 'process execution rejects %ENV aliased through a hash reference'); + like($@, qr/^%ENV is aliased to another variable while running with -T switch/, + 'hash-reference %ENV alias reports the Perl security error'); + } + { + local *ENV = *taint_env_alias; + my $ok = eval { system 'taint-command-that-does-not-exist'; 1 }; + ok(!$ok, 'process execution rejects %ENV aliased through a typeglob'); + like($@, qr/^%ENV is aliased to %taint_env_alias while running with -T switch/, + 'typeglob %ENV alias reports the Perl security error'); + } +} + { package TaintStoreProbe; our @seen; From 34555c582249b7141c2a967060eb58df94646881 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Sun, 9 Aug 2026 23:16:48 +0200 Subject: [PATCH 25/26] fix: avoid taint metadata side effects Only inspect and propagate taint provenance while taint mode is active so normal execution does not add tied FETCH calls. Preserve taint across lvalue substring and UTF-8 mutations, and reuse a single tied substitution FETCH. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/operators/Operator.java | 4 +- .../runtime/operators/SprintfOperator.java | 4 +- .../org/perlonjava/runtime/operators/Vec.java | 3 +- .../perlonjava/runtime/perlmodule/Utf8.java | 9 ++++ .../runtime/regex/RuntimeRegex.java | 47 +++++++++++-------- .../runtime/runtimetypes/RuntimeScalar.java | 7 +++ .../runtimetypes/RuntimeSubstrLvalue.java | 10 ++++ src/test/resources/unit/taint_mode.t | 27 +++++++++++ src/test/resources/unit/tie_scalar.t | 16 +++++++ 9 files changed, 103 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/Operator.java b/src/main/java/org/perlonjava/runtime/operators/Operator.java index 7510795aae..12faf37776 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Operator.java +++ b/src/main/java/org/perlonjava/runtime/operators/Operator.java @@ -296,7 +296,7 @@ public static RuntimeList split(RuntimeScalar quotedRegex, RuntimeList args, int } } - if (string.isTainted()) { + if (GlobalContext.isTaintModeActive() && string.isTainted()) { for (RuntimeBase element : splitElements) { if (element instanceof RuntimeScalar scalar) { scalar.tainted = true; @@ -811,7 +811,7 @@ public static RuntimeBase repeat(RuntimeBase value, RuntimeScalar timesScalar, i scalarValue = value.scalar(); } RuntimeScalar rv = new RuntimeScalar(scalarValue.toString().repeat(Math.max(0, times))); - rv.formatPictureTainted = timesScalar.isTainted(); + rv.formatPictureTainted = GlobalContext.isTaintModeActive() && timesScalar.isTainted(); if (scalarValue.type == RuntimeScalarType.BYTE_STRING) { rv.type = RuntimeScalarType.BYTE_STRING; } diff --git a/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java b/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java index 9d1ed41390..b6122b5e64 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SprintfOperator.java @@ -176,7 +176,9 @@ private static RuntimeScalar sprintfInternal(RuntimeScalar runtimeScalar, Runtim ProcessResult processResult = processFormatSpecifierTracked(spec, list, argIndex, formatter, bytesMode); result.append(processResult.formatted); charsWritten += processResult.formatted.length(); - hasTaintedArgument |= usedArgumentIsTainted(spec, list, argIndex); + if (GlobalContext.isTaintModeActive()) { + hasTaintedArgument |= usedArgumentIsTainted(spec, list, argIndex); + } // Only update maxArgIndexUsed if this specifier actually consumed arguments if (spec.conversionChar != '%' || spec.widthFromArg) { diff --git a/src/main/java/org/perlonjava/runtime/operators/Vec.java b/src/main/java/org/perlonjava/runtime/operators/Vec.java index cfb2f043cd..0ae0e13deb 100644 --- a/src/main/java/org/perlonjava/runtime/operators/Vec.java +++ b/src/main/java/org/perlonjava/runtime/operators/Vec.java @@ -1,5 +1,6 @@ package org.perlonjava.runtime.operators; +import org.perlonjava.runtime.runtimetypes.GlobalContext; import org.perlonjava.runtime.runtimetypes.PerlCompilerException; import org.perlonjava.runtime.runtimetypes.RuntimeList; import org.perlonjava.runtime.runtimetypes.RuntimeScalar; @@ -94,7 +95,7 @@ public static RuntimeScalar vec(RuntimeList args) throws PerlCompilerException { private static RuntimeVecLvalue vecResult(RuntimeScalar source, int offset, int bits, long value) { RuntimeVecLvalue result = new RuntimeVecLvalue(source, offset, bits, value); - result.tainted = source.isTainted(); + result.tainted = GlobalContext.isTaintModeActive() && source.isTainted(); return result; } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java b/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java index 7b91df75bb..8a94299a1e 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/Utf8.java @@ -108,6 +108,7 @@ public static RuntimeList upgrade(RuntimeArray args, int ctx) { throw new IllegalStateException("Bad number of arguments for upgrade() method"); } RuntimeScalar scalar = args.get(0); + boolean wasTainted = GlobalContext.isTaintModeActive() && scalar.isTainted(); String string = scalar.toString(); byte[] utf8Bytes = string.getBytes(StandardCharsets.UTF_8); @@ -125,6 +126,7 @@ public static RuntimeList upgrade(RuntimeArray args, int ctx) { // that already contains Unicode code points > 0xFF (e.g. "\x{100}"). // This is fine — we just flip the type and preserve the content as-is. scalar.set(string); + scalar.tainted = wasTainted; scalar.type = STRING; } else if (scalar.type != STRING) { // Other types (INTEGER, DOUBLE, UNDEF, etc.): convert to string and mark as STRING. @@ -140,6 +142,7 @@ public static RuntimeList upgrade(RuntimeArray args, int ctx) { // WARNING: Do NOT skip this set() call, as it will cause regressions where // utf8::upgrade() corrupts Unicode strings to wrong values (e.g., U+0100 -> U+0000). scalar.set(string); + scalar.tainted = wasTainted; scalar.type = STRING; } // If scalar.type == STRING: already upgraded, do nothing. @@ -162,6 +165,7 @@ public static RuntimeList downgrade(RuntimeArray args, int ctx) { throw new IllegalStateException("Bad number of arguments for downgrade() method"); } RuntimeScalar scalar = args.get(0); + boolean wasTainted = GlobalContext.isTaintModeActive() && scalar.isTainted(); boolean failOk = args.size() == 2 && args.get(1).getBoolean(); String string = scalar.toString(); @@ -189,6 +193,7 @@ public static RuntimeList downgrade(RuntimeArray args, int ctx) { } catch (PerlCompilerException e) { scalar.value = decodedBytes; } + scalar.tainted = wasTainted; scalar.type = BYTE_STRING; } return new RuntimeScalar(true).getList(); @@ -220,9 +225,11 @@ public static RuntimeList encode(RuntimeArray args, int ctx) { throw new IllegalStateException("Bad number of arguments for encode() method"); } RuntimeScalar scalar = args.get(0); + boolean wasTainted = GlobalContext.isTaintModeActive() && scalar.isTainted(); String string = scalar.toString(); byte[] utf8Bytes = string.getBytes(StandardCharsets.UTF_8); scalar.set(new String(utf8Bytes, StandardCharsets.ISO_8859_1)); + scalar.tainted = wasTainted; scalar.type = BYTE_STRING; return new RuntimeScalar().getList(); } @@ -239,6 +246,7 @@ public static RuntimeList decode(RuntimeArray args, int ctx) { throw new IllegalStateException("Bad number of arguments for decode() method"); } RuntimeScalar scalar = args.get(0); + boolean wasTainted = GlobalContext.isTaintModeActive() && scalar.isTainted(); String string = scalar.toString(); // utf8::decode expects octet data (0-255). If the string contains @@ -265,6 +273,7 @@ public static RuntimeList decode(RuntimeArray args, int ctx) { CharBuffer decoded = decoder.decode(ByteBuffer.wrap(bytes)); String decodedStr = decoded.toString(); scalar.set(decodedStr); + scalar.tainted = wasTainted; // Per Perl 5 docs: "The UTF-8 flag is turned on only if the string // contains a multi-byte UTF-8 character (i.e., any char above 0x7F // after decoding)." For pure ASCII input (all chars <= 0x7F), the diff --git a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java index c637332a12..b0112d574c 100644 --- a/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java +++ b/src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java @@ -1438,8 +1438,9 @@ private static RuntimeBase matchRegexDirect(RuntimeScalar quotedRegex, RuntimeSc } found = true; - lastMatchResultsTainted = quotedRegex.isTainted() - || (regex.regexFlags.taintResults() && string.isTainted()); + lastMatchResultsTainted = GlobalContext.isTaintModeActive() + && (quotedRegex.isTainted() + || (regex.regexFlags.taintResults() && string.isTainted())); lastMatchWasByteString = (string.type == RuntimeScalarType.BYTE_STRING); int captureCount = matcher.groupCount(); @@ -2070,11 +2071,15 @@ private static void updateReplacementMatchState(RuntimeRegex regex, Matcher matc } public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar string, int ctx) { - // Convert the input string to a Java string - String inputStr = string.toString(); - boolean wasByteString = (string.type == RuntimeScalarType.BYTE_STRING); - boolean inputTainted = string.isTainted(); - boolean patternTainted = quotedRegex.isTainted(); + // Resolve a tied target exactly once for all reads. Keep `string` as + // the lvalue used for STORE after the substitution. + RuntimeScalar inputValue = string.type == RuntimeScalarType.TIED_SCALAR + ? string.tiedFetch() : string; + String inputStr = inputValue.toString(); + boolean wasByteString = (inputValue.type == RuntimeScalarType.BYTE_STRING); + boolean taintMode = GlobalContext.isTaintModeActive(); + boolean inputTainted = taintMode && inputValue.isTainted(); + boolean patternTainted = taintMode && quotedRegex.isTainted(); boolean resultNeedsUtf8 = !wasByteString; // Extract the regex pattern from the quotedRegex object @@ -2138,7 +2143,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar regex.emitWarningsOnUse(); - Pattern pattern = regex.selectPattern(string, inputStr); + Pattern pattern = regex.selectPattern(inputValue, inputStr); // Select appropriate pattern based on string's UTF-8 flag (same logic as matchRegex) if (pattern == regex.pattern && regex.patternUnicode != null && regex.patternUnicode != regex.pattern) { @@ -2148,9 +2153,9 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar } else if (hasInlineAsciiModifier(regex.patternString)) { // Inline (?a...) in pattern - use ASCII to be safe pattern = regex.pattern; - } else if (Utf8.isUtf8(string)) { + } else if (Utf8.isUtf8(inputValue)) { // UTF-8 string - use Unicode matching for \w, \d, \s semantics - pattern = regex.selectPattern(string, inputStr); + pattern = regex.selectPattern(inputValue, inputStr); } // else: BYTE_STRING - keep ASCII pattern (default) } @@ -2215,7 +2220,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar } found++; - updateReplacementMatchState(regex, matcher, inputStr, string, captureResultsTainted); + updateReplacementMatchState(regex, matcher, inputStr, inputValue, captureResultsTainted); String replacementStr; if (replacementIsCode) { @@ -2227,7 +2232,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementResultTainted |= replacementValue.isTainted(); + replacementResultTainted |= taintMode && replacementValue.isTainted(); replacementStr = replacementValue.toString(); } else { // Replace the match with the replacement string @@ -2235,7 +2240,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementResultTainted |= replacementValue.isTainted(); + replacementResultTainted |= taintMode && replacementValue.isTainted(); replacementStr = replacementValue.toString(); } @@ -2280,7 +2285,7 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar && retryMatcher.start() == zeroLengthOffset && retryMatcher.end() > zeroLengthOffset) { found++; - updateReplacementMatchState(regex, retryMatcher, inputStr, string, captureResultsTainted); + updateReplacementMatchState(regex, retryMatcher, inputStr, inputValue, captureResultsTainted); String retryReplacementStr; if (replacementIsCode) { @@ -2290,14 +2295,14 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementResultTainted |= replacementValue.isTainted(); + replacementResultTainted |= taintMode && replacementValue.isTainted(); retryReplacementStr = replacementValue.toString(); } else { RuntimeScalar replacementValue = stringifyReplacementValue(replacement); if (Utf8.isUtf8(replacementValue)) { resultNeedsUtf8 = true; } - replacementResultTainted |= replacementValue.isTainted(); + replacementResultTainted |= taintMode && replacementValue.isTainted(); retryReplacementStr = replacementValue.toString(); } @@ -2362,15 +2367,17 @@ public static RuntimeBase replaceRegex(RuntimeScalar quotedRegex, RuntimeScalar return rv; } else { // Save the modified string back to the original scalar - string.set(finalResult); - string.tainted = inputTainted || patternTainted || replacementResultTainted; + RuntimeScalar substitutedValue = new RuntimeScalar(finalResult); + substitutedValue.tainted = inputTainted || patternTainted || replacementResultTainted; if (wasByteString && !resultNeedsUtf8 && !containsWideChars(finalResult)) { - string.type = RuntimeScalarType.BYTE_STRING; + substitutedValue.type = RuntimeScalarType.BYTE_STRING; } + string.set(substitutedValue); + string.tainted = substitutedValue.tainted; // Return the number of substitutions made RuntimeScalar count = RuntimeScalarCache.getScalarInt(found); if (regex.regexFlags.isGlobalMatch() && (inputTainted || patternTainted)) { - count = count.propagateTaint(string, quotedRegex); + count = count.propagateTaint(inputValue, quotedRegex); } return count; } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 6b0e8a692b..7b93d1db78 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -1093,6 +1093,13 @@ public RuntimeScalar taintFromExternalInput() { /** Mark this scalar tainted when any scalar input to an operation is tainted. */ public RuntimeScalar propagateTaint(RuntimeScalar... inputs) { + // Outside taint mode there is no provenance to propagate. In + // particular, asking a tied scalar whether it is tainted would invoke + // FETCH a second time after the operator has already fetched its + // value. + if (!GlobalContext.isTaintModeActive()) { + return this; + } for (RuntimeScalar input : inputs) { if (input != null && input.isTainted()) { if (isTainted()) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeSubstrLvalue.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeSubstrLvalue.java index 5c6724a40d..d32b10065a 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeSubstrLvalue.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeSubstrLvalue.java @@ -129,6 +129,15 @@ public RuntimeScalar set(RuntimeScalar value) { // Update the parent RuntimeScalar with the modified string RuntimeScalar updated = new RuntimeScalar(updatedValue.toString()); + // Assignment through substr is an in-place mutation. Perl preserves + // existing taint on the target and also propagates taint from the + // replacement value. + if (GlobalContext.isTaintModeActive()) { + // The proxy cached its parent's provenance when it was created; + // consulting the tied parent again here would perform another + // FETCH for the same lvalue operation. + updated.tainted = this.tainted || value.isTainted(); + } // Preserve BYTE_STRING type: if the parent was a byte string and the replacement // doesn't introduce UTF-8 characters, keep the result as BYTE_STRING. // In Perl, substr assignment on a byte string with a byte replacement stays bytes. @@ -167,6 +176,7 @@ public RuntimeScalar set(RuntimeScalar value) { this.length = replacementLength; this.type = value.type; this.value = newValue; + this.tainted = updated.tainted; return this; } diff --git a/src/test/resources/unit/taint_mode.t b/src/test/resources/unit/taint_mode.t index 024058eee2..d204ca7d59 100644 --- a/src/test/resources/unit/taint_mode.t +++ b/src/test/resources/unit/taint_mode.t @@ -24,6 +24,33 @@ ok(!tainted($^O), '$^O is not tainted'); ok(tainted($empty_taint), 'substr preserves source taint'); ok(tainted($text), 'concatenation propagates taint'); +my $substr_target = $ENV{PATH}; +substr($substr_target, 0) = 'replacement'; +ok(tainted($substr_target), 'lvalue substr assignment preserves target taint'); + +my $utf8_taint = "ascii$empty_taint"; +utf8::encode($utf8_taint); +ok(tainted($utf8_taint), 'utf8::encode preserves taint'); +utf8::decode($utf8_taint); +ok(tainted($utf8_taint), 'utf8::decode preserves taint'); +utf8::upgrade($utf8_taint); +ok(tainted($utf8_taint), 'utf8::upgrade preserves taint'); +utf8::downgrade($utf8_taint); +ok(tainted($utf8_taint), 'utf8::downgrade preserves taint'); + +{ + package TaintFetchProbe; + sub TIESCALAR { bless { value => $_[1], fetches => 0 }, $_[0] } + sub FETCH { ++$_[0]{fetches}; $_[0]{value} } + sub STORE { $_[0]{value} = $_[1] } + + package main; + tie my $tied_substitution, 'TaintFetchProbe', "abc$empty_taint"; + my $probe = tied $tied_substitution; + $tied_substitution =~ s/a/x/; + is($probe->{fetches}, 1, 'tainted substitution fetches a tied target once'); +} + my $copy = $text; ok(tainted($copy), 'assignment propagates taint'); { diff --git a/src/test/resources/unit/tie_scalar.t b/src/test/resources/unit/tie_scalar.t index af2ecc2ce4..7be835c68b 100644 --- a/src/test/resources/unit/tie_scalar.t +++ b/src/test/resources/unit/tie_scalar.t @@ -124,6 +124,22 @@ subtest 'FETCH operations' => sub { is($obj->{fetch_count}, 6, 'FETCH called in numeric context'); }; +subtest 'operators fetch once' => sub { + my $scalar; + my $obj = tie $scalar, 'TiedScalar'; + $scalar = 12; + + my $negative = -$scalar; + is($negative, -12, 'unary minus uses the tied value'); + is($obj->{fetch_count}, 1, 'unary minus calls FETCH once'); + + $obj->{fetch_count} = 0; + my $quotient = $scalar / 3; + is($quotient, 4, 'division uses the tied value'); + is($obj->{fetch_count}, 1, 'division calls FETCH once'); + +}; + subtest 'STORE operations' => sub { my $scalar; my $obj = tie $scalar, 'TiedScalar'; From 7edc8dd2c4eba112dbdf668564f13dfb65c4ca95 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Mon, 10 Aug 2026 09:24:55 +0200 Subject: [PATCH 26/26] docs: document taint mode support Add taint mode to the work-in-progress changelog and update the feature matrix for -T support, backend parity, and the remaining -t limitation. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- docs/about/changelog.md | 4 ++++ docs/reference/feature-matrix.md | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 412caf7a63..1cbc33ae93 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -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. diff --git a/docs/reference/feature-matrix.md b/docs/reference/feature-matrix.md index 920baa173e..60ed9ea926 100644 --- a/docs/reference/feature-matrix.md +++ b/docs/reference/feature-matrix.md @@ -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: @@ -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. @@ -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.