From 63cde2daadc705edf086f2213b48c8c547f98358 Mon Sep 17 00:00:00 2001 From: Daniel Beck Date: Mon, 25 Oct 2021 12:52:23 +0000 Subject: [PATCH] [SECURITY-2455] --- core/src/main/java/hudson/FilePath.java | 291 ++++--- .../main/java/jenkins/SoloFilePathFilter.java | 38 +- .../security/s2m/FilePathRuleConfig.java | 24 +- .../jenkins/security/Security2455Test.java | 756 ++++++++++++++++++ .../security/s2m/AdminFilePathFilterTest.java | 14 +- .../security/Security2455Test/symlink.tar | Bin 0 -> 1536 bytes .../Security2455Test/unzipRemoteTest/file.zip | Bin 0 -> 478 bytes 7 files changed, 991 insertions(+), 132 deletions(-) create mode 100644 test/src/test/java/jenkins/security/Security2455Test.java create mode 100644 test/src/test/resources/jenkins/security/Security2455Test/symlink.tar create mode 100644 test/src/test/resources/jenkins/security/Security2455Test/unzipRemoteTest/file.zip diff --git a/core/src/main/java/hudson/FilePath.java b/core/src/main/java/hudson/FilePath.java index 9c5bb69e044a..3245ae63c049 100644 --- a/core/src/main/java/hudson/FilePath.java +++ b/core/src/main/java/hudson/FilePath.java @@ -34,7 +34,6 @@ import com.jcraft.jzlib.GZIPOutputStream; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.Launcher.LocalLauncher; import hudson.Launcher.RemoteLauncher; @@ -216,6 +215,11 @@ public final class FilePath implements SerializableOnlyOverRemoting { */ private static final int MAX_REDIRECTS = 20; + /** + * Escape hatch for some additional protections against sending callables intended to be locally used only + */ + private static /* non-final for Groovy */ boolean REJECT_LOCAL_CALLABLE_DESERIALIZATION = SystemProperties.getBoolean(FilePath.class.getName() + ".rejectLocalCallableDeserialization", true); + /** * When this {@link FilePath} represents the remote path, * this field is always non-null on the controller (the field represents @@ -239,18 +243,6 @@ public final class FilePath implements SerializableOnlyOverRemoting { */ private /*final*/ String remote; - /** - * If this {@link FilePath} is deserialized to handle file access request from a remote computer, - * this field is set to the filter that performs access control. - * - *

- * If null, no access control is needed. - * - * @see #filterNonNull() - */ - private transient @Nullable - SoloFilePathFilter filter; - /** * Creates a {@link FilePath} that represents a path on the given node. * @@ -527,7 +519,7 @@ public int archive(final ArchiverFactory factory, OutputStream os, final DirScan final OutputStream out = channel != null ? new RemoteOutputStream(os) : os; return act(new Archive(factory, out, scanner, verificationRoot, noFollowLinks)); } - private class Archive extends SecureFileCallable { + private static class Archive extends SecureFileCallable { private final ArchiverFactory factory; private final OutputStream out; private final DirScanner scanner; @@ -573,14 +565,14 @@ public int archive(final ArchiverFactory factory, OutputStream os, final String */ public void unzip(final FilePath target) throws IOException, InterruptedException { // TODO: post release, re-unite two branches by introducing FileStreamCallable that resolves InputStream - if (this.channel!=target.channel) {// local -> remote or remote->local + if (channel != target.channel) {// local -> remote or remote->local final RemoteInputStream in = new RemoteInputStream(read(), Flag.GREEDY); target.act(new UnzipRemote(in)); } else {// local -> local or remote->remote - target.act(new UnzipLocal()); + target.act(new UnzipLocal(this)); } } - private class UnzipRemote extends SecureFileCallable { + private static class UnzipRemote extends SecureFileCallable { private final RemoteInputStream in; UnzipRemote(RemoteInputStream in) { this.in = in; @@ -592,14 +584,30 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru } private static final long serialVersionUID = 1L; } - private class UnzipLocal extends SecureFileCallable { + private static class UnzipLocal extends SecureFileCallable { + + private final FilePath filePath; + + private UnzipLocal(FilePath filePath) { + this.filePath = filePath; + } + @Override public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException { - assert !FilePath.this.isRemote(); // this.channel==target.channel above - unzip(dir, reading(new File(FilePath.this.getRemote()))); // shortcut to local file + if (this.filePath.isRemote()) { + throw new IllegalStateException("Expected local path for file: " + filePath); // this.channel==target.channel above + } + unzip(dir, reading(new File(this.filePath.getRemote()))); // shortcut to local file return null; } private static final long serialVersionUID = 1L; + + protected Object readResolve() { + if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) { + throw new IllegalStateException("This callable is not intended to be sent through a channel"); + } + return this; + } } /** @@ -613,39 +621,52 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru * @see #untarFrom(InputStream, TarCompression) */ public void untar(final FilePath target, final TarCompression compression) throws IOException, InterruptedException { + final FilePath source = FilePath.this; // TODO: post release, re-unite two branches by introducing FileStreamCallable that resolves InputStream - if (this.channel!=target.channel) {// local -> remote or remote->local - final RemoteInputStream in = new RemoteInputStream(read(), Flag.GREEDY); - target.act(new UntarRemote(compression, in)); + if (source.channel != target.channel) {// local -> remote or remote->local + final RemoteInputStream in = new RemoteInputStream(source.read(), Flag.GREEDY); + target.act(new UntarRemote(source.getName(), compression, in)); } else {// local -> local or remote->remote - target.act(new UntarLocal(compression)); + target.act(new UntarLocal(source, compression)); } } - private class UntarRemote extends SecureFileCallable { + private static class UntarRemote extends SecureFileCallable { private final TarCompression compression; private final RemoteInputStream in; - UntarRemote(TarCompression compression, RemoteInputStream in) { + private final String name; + UntarRemote(String name, TarCompression compression, RemoteInputStream in) { this.compression = compression; this.in = in; + this.name = name; } @Override public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException { - readFromTar(FilePath.this.getName(), dir, compression.extract(in)); + readFromTar(name, dir, compression.extract(in)); return null; } private static final long serialVersionUID = 1L; } - private class UntarLocal extends SecureFileCallable { + private static class UntarLocal extends SecureFileCallable { private final TarCompression compression; - UntarLocal(TarCompression compression) { + private final FilePath filePath; + + UntarLocal(FilePath source, TarCompression compression) { + this.filePath = source; this.compression = compression; } @Override public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException { - readFromTar(FilePath.this.getName(), dir, compression.extract(FilePath.this.read())); + readFromTar(this.filePath.getName(), dir, compression.extract(this.filePath.read())); return null; } private static final long serialVersionUID = 1L; + + protected Object readResolve() { + if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) { + throw new IllegalStateException("This callable is not intended to be sent through a channel"); + } + return this; + } } /** @@ -660,7 +681,7 @@ public void unzipFrom(InputStream _in) throws IOException, InterruptedException final InputStream in = new RemoteInputStream(_in, Flag.GREEDY); act(new UnzipFrom(in)); } - private class UnzipFrom extends SecureFileCallable { + private static class UnzipFrom extends SecureFileCallable { private final InputStream in; UnzipFrom(InputStream in) { this.in = in; @@ -673,7 +694,7 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException { private static final long serialVersionUID = 1L; } - private void unzip(File dir, InputStream in) throws IOException { + private static void unzip(File dir, InputStream in) throws IOException { File tmpFile = File.createTempFile("tmpzip", null); // uses java.io.tmpdir try { // TODO why does this not simply use ZipInputStream? @@ -685,7 +706,7 @@ private void unzip(File dir, InputStream in) throws IOException { } } - private void unzip(File dir, File zipFile) throws IOException { + private static void unzip(File dir, File zipFile) throws IOException { dir = dir.getAbsoluteFile(); // without absolutization, getParentFile below seems to fail ZipFile zip = new ZipFile(zipFile); Enumeration entries = zip.getEntries(); @@ -734,7 +755,7 @@ private static class Absolutize extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File f, VirtualChannel channel) throws IOException { - return f.getAbsolutePath(); + return stating(f).getAbsolutePath(); } } @@ -754,7 +775,7 @@ private static class HasSymlink extends SecureFileCallable { @Override public Boolean invoke(File f, VirtualChannel channel) throws IOException { - return isSymlink(f, verificationRoot, noFollowLinks); + return isSymlink(stating(f), verificationRoot, noFollowLinks); } } @@ -792,7 +813,7 @@ public boolean accept(File file) { public void symlinkTo(final String target, final TaskListener listener) throws IOException, InterruptedException { act(new SymlinkTo(target, listener)); } - private class SymlinkTo extends SecureFileCallable { + private static class SymlinkTo extends SecureFileCallable { private final String target; private final TaskListener listener; SymlinkTo(String target, TaskListener listener) { @@ -802,8 +823,7 @@ private class SymlinkTo extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { - symlinking(f); - Util.createSymlink(f.getParentFile(), target, f.getName(), listener); + Util.createSymlink(symlinking(f).getParentFile(), target, f.getName(), listener); return null; } } @@ -818,7 +838,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException, Interrupt public String readLink() throws IOException, InterruptedException { return act(new ReadLink()); } - private class ReadLink extends SecureFileCallable { + private static class ReadLink extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { @@ -896,7 +916,7 @@ public void untarFrom(InputStream _in, final TarCompression compression) throws _in.close(); } } - private class UntarFrom extends SecureFileCallable { + private static class UntarFrom extends SecureFileCallable { private final TarCompression compression; private final InputStream in; UntarFrom(TarCompression compression, InputStream in) { @@ -905,7 +925,7 @@ private class UntarFrom extends SecureFileCallable { } @Override public Void invoke(File dir, VirtualChannel channel) throws IOException { - readFromTar("input stream",dir, compression.extract(in)); + readFromTar("input stream",dir, compression.extract(in)); // #writing etc. are called in #readFromTar return null; } private static final long serialVersionUID = 1L; @@ -1157,7 +1177,7 @@ private T act(final FileCallable callable, ClassLoader cl) throws IOExcep if(channel!=null) { // run this on a remote system try { - DelegatingCallable wrapper = new FileCallableWrapper<>(callable, cl); + DelegatingCallable wrapper = new FileCallableWrapper<>(callable, cl, this); for (FileCallableWrapperFactory factory : ExtensionList.lookup(FileCallableWrapperFactory.class)) { wrapper = factory.wrap(wrapper); } @@ -1233,7 +1253,7 @@ protected void after() {} */ public Future actAsync(final FileCallable callable) throws IOException, InterruptedException { try { - DelegatingCallable wrapper = new FileCallableWrapper<>(callable); + DelegatingCallable wrapper = new FileCallableWrapper<>(callable, this); for (FileCallableWrapperFactory factory : ExtensionList.lookup(FileCallableWrapperFactory.class)) { wrapper = factory.wrap(wrapper); } @@ -1302,7 +1322,7 @@ private static class ToURI extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public URI invoke(File f, VirtualChannel channel) { - return f.toURI(); + return stating(f).toURI(); } } @@ -1340,7 +1360,7 @@ public void mkdirs() throws IOException, InterruptedException { throw new IOException("Failed to mkdirs: " + remote); } } - private class Mkdirs extends SecureFileCallable { + private static class Mkdirs extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Boolean invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { @@ -1366,7 +1386,7 @@ public void deleteSuffixesRecursive() throws IOException, InterruptedException { /** * Deletes all suffixed directories that are separated by {@link WorkspaceList#COMBINATOR}, including all its contents recursively. */ - private class DeleteSuffixesRecursive extends SecureFileCallable { + private static class DeleteSuffixesRecursive extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override @@ -1398,7 +1418,7 @@ private static File[] listParentFiles(File f) { public void deleteRecursive() throws IOException, InterruptedException { act(new DeleteRecursive()); } - private class DeleteRecursive extends SecureFileCallable { + private static class DeleteRecursive extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { @@ -1413,7 +1433,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException { public void deleteContents() throws IOException, InterruptedException { act(new DeleteContents()); } - private class DeleteContents extends SecureFileCallable { + private static class DeleteContents extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { @@ -1516,7 +1536,7 @@ public FilePath createTempFile(final String prefix, final String suffix) throws throw new IOException("Failed to create a temp file on "+remote,e); } } - private class CreateTempFile extends SecureFileCallable { + private static class CreateTempFile extends SecureFileCallable { private final String prefix; private final String suffix; CreateTempFile(String prefix, String suffix) { @@ -1526,7 +1546,8 @@ private class CreateTempFile extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File dir, VirtualChannel channel) throws IOException { - File f = writing(File.createTempFile(prefix, suffix, dir)); + creating(new File(dir, prefix + "-security-check-dummy-" + suffix)); // use fake file to check access before creation + File f = creating(File.createTempFile(prefix, suffix, dir)); return f.getName(); } } @@ -1580,7 +1601,7 @@ public FilePath createTextTempFile(final String prefix, final String suffix, fin throw new IOException("Failed to create a temp file on "+remote,e); } } - private final class CreateTextTempFile extends SecureFileCallable { + private static class CreateTextTempFile extends SecureFileCallable { private static final long serialVersionUID = 1L; private final boolean inThisDirectory; private final String prefix; @@ -1601,6 +1622,7 @@ public String invoke(File dir, VirtualChannel channel) throws IOException { File f; try { + creating(new File(dir, prefix + "-security-check-dummy-" + suffix)); // use fake file to check access before creation f = creating(File.createTempFile(prefix, suffix, dir)); } catch (IOException e) { throw new IOException("Failed to create a temporary directory in "+dir,e); @@ -1642,7 +1664,7 @@ public FilePath createTempDir(final String prefix, final String suffix) throws I throw new IOException("Failed to create a temp directory on "+remote,e); } } - private class CreateTempDir extends SecureFileCallable { + private static class CreateTempDir extends SecureFileCallable { private final String name; CreateTempDir(String name) { this.name = name; @@ -1650,6 +1672,7 @@ private class CreateTempDir extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File dir, VirtualChannel channel) throws IOException { + mkdirsing(new File(dir, name + "-security-test")); // ensure access Path tempPath; final boolean isPosix = FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); @@ -1661,8 +1684,8 @@ public String invoke(File dir, VirtualChannel channel) throws IOException { tempPath = Files.createTempDirectory(Util.fileToPath(dir), name); } - if (tempPath.toFile() == null) { - throw new IOException("Failed to obtain file from path " + dir + " on " + remote); + if (mkdirsing(tempPath.toFile()) == null) { + throw new IOException("Failed to obtain file from path " + dir); } return tempPath.toFile().getName(); } @@ -1677,7 +1700,7 @@ public boolean delete() throws IOException, InterruptedException { act(new Delete()); return true; } - private class Delete extends SecureFileCallable { + private static class Delete extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { @@ -1692,7 +1715,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException { public boolean exists() throws IOException, InterruptedException { return act(new Exists()); } - private class Exists extends SecureFileCallable { + private static class Exists extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Boolean invoke(File f, VirtualChannel channel) throws IOException { @@ -1710,7 +1733,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException { public long lastModified() throws IOException, InterruptedException { return act(new LastModified()); } - private class LastModified extends SecureFileCallable { + private static class LastModified extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Long invoke(File f, VirtualChannel channel) throws IOException { @@ -1726,7 +1749,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException { public void touch(final long timestamp) throws IOException, InterruptedException { act(new Touch(timestamp)); } - private class Touch extends SecureFileCallable { + private static class Touch extends SecureFileCallable { private final long timestamp; Touch(long timestamp) { this.timestamp = timestamp; @@ -1750,7 +1773,7 @@ private void setLastModifiedIfPossible(final long timestamp) throws IOException, LOGGER.warning(message); } } - private class SetLastModified extends SecureFileCallable { + private static class SetLastModified extends SecureFileCallable { private final long timestamp; SetLastModified(long timestamp) { this.timestamp = timestamp; @@ -1777,7 +1800,7 @@ public String invoke(File f, VirtualChannel channel) throws IOException { public boolean isDirectory() throws IOException, InterruptedException { return act(new IsDirectory()); } - private final class IsDirectory extends SecureFileCallable { + private static class IsDirectory extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Boolean invoke(File f, VirtualChannel channel) throws IOException { @@ -1793,7 +1816,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException { public long length() throws IOException, InterruptedException { return act(new Length()); } - private class Length extends SecureFileCallable { + private static class Length extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Long invoke(File f, VirtualChannel channel) throws IOException { @@ -1808,7 +1831,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException { public long getFreeDiskSpace() throws IOException, InterruptedException { return act(new GetFreeDiskSpace()); } - private static class GetFreeDiskSpace extends SecureFileCallable { + private static class GetFreeDiskSpace extends MasterToSlaveFileCallable { private static final long serialVersionUID = 1L; @Override public Long invoke(File f, VirtualChannel channel) throws IOException { @@ -1823,7 +1846,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException { public long getTotalDiskSpace() throws IOException, InterruptedException { return act(new GetTotalDiskSpace()); } - private static class GetTotalDiskSpace extends SecureFileCallable { + private static class GetTotalDiskSpace extends MasterToSlaveFileCallable { private static final long serialVersionUID = 1L; @Override public Long invoke(File f, VirtualChannel channel) throws IOException { @@ -1838,7 +1861,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException { public long getUsableDiskSpace() throws IOException, InterruptedException { return act(new GetUsableDiskSpace()); } - private static class GetUsableDiskSpace extends SecureFileCallable { + private static class GetUsableDiskSpace extends MasterToSlaveFileCallable { private static final long serialVersionUID = 1L; @Override public Long invoke(File f, VirtualChannel channel) throws IOException { @@ -1870,7 +1893,7 @@ public void chmod(final int mask) throws IOException, InterruptedException { if(!isUnix() || mask==-1) return; act(new Chmod(mask)); } - private class Chmod extends SecureFileCallable { + private static class Chmod extends SecureFileCallable { private static final long serialVersionUID = 1L; private final int mask; Chmod(int mask) { @@ -1909,7 +1932,7 @@ public int mode() throws IOException, InterruptedException, PosixException { if(!isUnix()) return -1; return act(new Mode()); } - private class Mode extends SecureFileCallable { + private static class Mode extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Integer invoke(File f, VirtualChannel channel) throws IOException { @@ -1979,7 +2002,7 @@ public List list(final FileFilter filter) throws IOException, Interrup } return act(new ListFilter(filter), (filter != null ? filter : this).getClass().getClassLoader()); } - private class ListFilter extends SecureFileCallable> { + private static class ListFilter extends SecureFileCallable> { private final FileFilter filter; ListFilter(FileFilter filter) { this.filter = filter; @@ -2045,7 +2068,7 @@ public FilePath[] list(final String includes, final String excludes) throws IOEx public FilePath[] list(final String includes, final String excludes, final boolean defaultExcludes) throws IOException, InterruptedException { return act(new ListGlob(includes, excludes, defaultExcludes)); } - private class ListGlob extends SecureFileCallable { + private static class ListGlob extends SecureFileCallable { private final String includes; private final String excludes; private final boolean defaultExcludes; @@ -2061,7 +2084,7 @@ public FilePath[] invoke(File f, VirtualChannel channel) throws IOException { FilePath[] r = new FilePath[files.length]; for( int i=0; i { + private static class Read extends SecureFileCallable { private static final long serialVersionUID = 1L; private final Pipe p; private String verificationRoot; @@ -2251,7 +2274,7 @@ public int read(byte[] b) throws IOException { return new java.util.zip.GZIPInputStream(p.getIn()); } - private class OffsetPipeSecureFileCallable extends SecureFileCallable { + private static class OffsetPipeSecureFileCallable extends SecureFileCallable { private static final long serialVersionUID = 1L; private Pipe p; @@ -2284,7 +2307,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException { public String readToString() throws IOException, InterruptedException { return act(new ReadToString()); } - private final class ReadToString extends SecureFileCallable { + private static class ReadToString extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { @@ -2308,12 +2331,12 @@ public OutputStream write() throws IOException, InterruptedException { if(channel==null) { File f = new File(remote).getAbsoluteFile(); mkdirs(f.getParentFile()); - return Files.newOutputStream(fileToPath(writing(f))); + return Files.newOutputStream(fileToPath(writing(f))); // TODO #writing seems unnecessary on a local file } return act(new WritePipe()); } - private class WritePipe extends SecureFileCallable { + private static class WritePipe extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public OutputStream invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { @@ -2333,7 +2356,7 @@ public OutputStream invoke(File f, VirtualChannel channel) throws IOException, I public void write(final String content, final String encoding) throws IOException, InterruptedException { act(new Write(encoding, content)); } - private class Write extends SecureFileCallable { + private static class Write extends SecureFileCallable { private static final long serialVersionUID = 1L; private final String encoding; private final String content; @@ -2359,7 +2382,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException { public String digest() throws IOException, InterruptedException { return act(new Digest()); } - private class Digest extends SecureFileCallable { + private static class Digest extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public String invoke(File f, VirtualChannel channel) throws IOException { @@ -2377,7 +2400,7 @@ public void renameTo(final FilePath target) throws IOException, InterruptedExcep } act(new RenameTo(target)); } - private class RenameTo extends SecureFileCallable { + private static class RenameTo extends SecureFileCallable { private final FilePath target; RenameTo(FilePath target) { this.target = target; @@ -2385,7 +2408,7 @@ private class RenameTo extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { - Files.move(fileToPath(reading(f)), fileToPath(creating(new File(target.remote))), LinkOption.NOFOLLOW_LINKS); + Files.move(fileToPath(deleting(reading(f))), fileToPath(writing(creating(new File(target.remote)))), LinkOption.NOFOLLOW_LINKS); return null; } } @@ -2401,7 +2424,7 @@ public void moveAllChildrenTo(final FilePath target) throws IOException, Interru } act(new MoveAllChildrenTo(target)); } - private class MoveAllChildrenTo extends SecureFileCallable { + private static class MoveAllChildrenTo extends SecureFileCallable { private final FilePath target; MoveAllChildrenTo(FilePath target) { this.target = target; @@ -2412,14 +2435,14 @@ public Void invoke(File f, VirtualChannel channel) throws IOException { // JENKINS-16846: if f.getName() is the same as one of the files/directories in f, // then the rename op will fail File tmp = new File(f.getAbsolutePath()+".__rename"); - if (!f.renameTo(tmp)) + if (!deleting(f).renameTo(creating(tmp))) throw new IOException("Failed to rename "+f+" to "+tmp); File t = new File(target.getRemote()); for(File child : reading(tmp).listFiles()) { File target = new File(t, child.getName()); - if(!stating(child).renameTo(creating(target))) + if(!deleting(reading(child)).renameTo(writing(creating(target)))) throw new IOException("Failed to rename "+child+" to "+target); } deleting(tmp).delete(); @@ -2456,7 +2479,7 @@ public void copyToWithPermission(FilePath target) throws IOException, Interrupte target.chmod(mode()); target.setLastModifiedIfPossible(lastModified()); } - private class CopyToWithPermission extends SecureFileCallable { + private static class CopyToWithPermission extends SecureFileCallable { private final FilePath target; CopyToWithPermission(FilePath target) { this.target = target; @@ -2484,7 +2507,7 @@ public void copyTo(OutputStream os) throws IOException, InterruptedException { // this is needed because I/O operation is asynchronous syncIO(); } - private class CopyTo extends SecureFileCallable { + private static class CopyTo extends SecureFileCallable { private static final long serialVersionUID = 4088559042349254141L; private final OutputStream out; CopyTo(OutputStream out) { @@ -2616,7 +2639,7 @@ public int copyRecursiveTo(final DirScanner scanner, final FilePath target, fina // local -> remote copy final Pipe pipe = Pipe.createLocalToRemote(); - Future future = target.actAsync(new ReadToTar(pipe, description, compression)); + Future future = target.actAsync(new ReadFromTar(target, pipe, description, compression)); Future future2 = actAsync(new WriteToTar(scanner, pipe, compression)); try { // JENKINS-9540 in case the reading side failed, report that error first @@ -2662,7 +2685,7 @@ private IOException ioWithCause(ExecutionException e) { ; } - private class CopyRecursiveLocal extends SecureFileCallable { + private static class CopyRecursiveLocal extends SecureFileCallable { private final FilePath target; private final DirScanner scanner; CopyRecursiveLocal(FilePath target, DirScanner scanner) { @@ -2675,7 +2698,9 @@ public Integer invoke(File base, VirtualChannel channel) throws IOException { if (!base.exists()) { return 0; } - assert target.channel == null; + if (target.channel != null) { + throw new IllegalStateException("Expected null channel for " + target); + } final File dest = new File(target.remote); final AtomicInteger count = new AtomicInteger(); scanner.scan(base, reading(new FileVisitor() { @@ -2729,12 +2754,14 @@ public void visitSymlink(File link, String target, String relativePath) throws I return count.get(); } } - private class ReadToTar extends SecureFileCallable { + private static class ReadFromTar extends SecureFileCallable { private final Pipe pipe; private final String description; private final TarCompression compression; + private final FilePath target; - ReadToTar(Pipe pipe, String description, @NonNull TarCompression compression) { + ReadFromTar(FilePath target, Pipe pipe, String description, @NonNull TarCompression compression) { + this.target = target; this.pipe = pipe; this.description = description; this.compression = compression; @@ -2743,12 +2770,12 @@ private class ReadToTar extends SecureFileCallable { @Override public Void invoke(File f, VirtualChannel channel) throws IOException { try (InputStream in = pipe.getIn()) { - readFromTar(remote + '/' + description, f, compression.extract(in)); + readFromTar(target.remote + '/' + description, f, compression.extract(in)); return null; } } } - private class WriteToTar extends SecureFileCallable { + private static class WriteToTar extends SecureFileCallable { private final DirScanner scanner; private final Pipe pipe; private final TarCompression compression; @@ -2760,10 +2787,10 @@ private class WriteToTar extends SecureFileCallable { private static final long serialVersionUID = 1L; @Override public Integer invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { - return writeToTar(new File(remote), scanner, compression.compress(pipe.getOut())); + return writeToTar(reading(f), scanner, compression.compress(pipe.getOut())); } } - private class CopyRecursiveRemoteToLocal extends SecureFileCallable { + private static class CopyRecursiveRemoteToLocal extends SecureFileCallable { private static final long serialVersionUID = 1L; private final Pipe pipe; private final DirScanner scanner; @@ -2776,7 +2803,7 @@ private class CopyRecursiveRemoteToLocal extends SecureFileCallable { @Override public Integer invoke(File f, VirtualChannel channel) throws IOException { try (OutputStream out = pipe.getOut()) { - return writeToTar(f, scanner, compression.compress(out)); + return writeToTar(reading(f), scanner, compression.compress(out)); } } } @@ -2808,7 +2835,7 @@ public int tar(OutputStream out, DirScanner scanner) throws IOException, Interru * @return * number of files/directories that are written. */ - private Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) throws IOException { + private static Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) throws IOException { Archiver tw = ArchiverFactory.TAR.create(out); try { scanner.scan(baseDir,reading(tw)); @@ -2822,14 +2849,15 @@ private Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) t * Reads from a tar stream and stores obtained files to the base dir. * Supports large files > 10 GB since 1.627 when this was migrated to use commons-compress. */ - private void readFromTar(String name, File baseDir, InputStream in) throws IOException { + private static void readFromTar(String name, File baseDir, InputStream in) throws IOException { + baseDir = baseDir.getCanonicalFile(); // TarInputStream t = new TarInputStream(in); try (TarArchiveInputStream t = new TarArchiveInputStream(in)) { TarArchiveEntry te; while ((te = t.getNextTarEntry()) != null) { - File f = new File(baseDir, te.getName()); - if (!f.toPath().normalize().startsWith(baseDir.toPath())) { + File f = new File(baseDir, te.getName()).getCanonicalFile(); + if (!f.toPath().startsWith(baseDir.toPath())) { throw new IOException( "Tar " + name + " contains illegal file name that breaks out of the target directory: " + te.getName()); } @@ -2838,12 +2866,11 @@ private void readFromTar(String name, File baseDir, InputStream in) throws IOExc } else { File parent = f.getParentFile(); if (parent != null) mkdirs(parent); - writing(f); if (te.isSymbolicLink()) { - new FilePath(f).symlinkTo(te.getLinkName(), TaskListener.NULL); + new FilePath(symlinking(f)).symlinkTo(te.getLinkName(), TaskListener.NULL); } else { - IOUtils.copy(t, f); + IOUtils.copy(t, writing(f)); f.setLastModified(te.getModTime().getTime()); int mode = te.getMode() & 0777; @@ -3280,14 +3307,12 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound ois.defaultReadObject(); if(ois.readBoolean()) { this.channel = channel; - this.filter = null; } else { this.channel = null; // If the remote channel wants us to create a FilePath that points to a local file, // we need to make sure the access control takes place. - // This covers the immediate case of FileCallables taking FilePath into reference closure implicitly, - // but it also covers more general case of FilePath sent as a return value or argument. - this.filter = SoloFilePathFilter.wrap(FilePathFilter.current()); + // Any FileCallables acting on a deserialized FilePath need to ensure they're subjecting it to + // access control checks like #reading(File) etc. } } @@ -3301,24 +3326,27 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound /** * Adapts {@link FileCallable} to {@link Callable}. */ - private class FileCallableWrapper implements DelegatingCallable { + private static class FileCallableWrapper implements DelegatingCallable { private final FileCallable callable; private transient ClassLoader classLoader; + private final FilePath filePath; - FileCallableWrapper(FileCallable callable) { + FileCallableWrapper(FileCallable callable, FilePath filePath) { this.callable = callable; this.classLoader = callable.getClass().getClassLoader(); + this.filePath = filePath; } - private FileCallableWrapper(FileCallable callable, ClassLoader classLoader) { + private FileCallableWrapper(FileCallable callable, ClassLoader classLoader, FilePath filePath) { this.callable = callable; this.classLoader = classLoader; + this.filePath = filePath; } @Override public T call() throws IOException { try { - return callable.invoke(new File(remote), Channel.current()); + return callable.invoke(new File(filePath.remote), filePath.channel); } catch (InterruptedException e) { throw new TunneledInterruptedException(e); } @@ -3413,15 +3441,16 @@ public ExplicitlySpecifiedDirScanner(Map files) { @NonNull public static final LocalChannel localChannel = new LocalChannel(threadPoolForRemoting); - private @NonNull SoloFilePathFilter filterNonNull() { - return filter!=null ? filter : UNRESTRICTED; + private static @NonNull SoloFilePathFilter filterNonNull() { + final SoloFilePathFilter filter = SoloFilePathFilter.wrap(FilePathFilter.current()); + return filter != null ? filter : UNRESTRICTED; } /** * Wraps {@link FileVisitor} to notify read access to {@link FilePathFilter}. */ - private FileVisitor reading(final FileVisitor v) { - final FilePathFilter filter = FilePathFilter.current(); + private static FileVisitor reading(final FileVisitor v) { + final FilePathFilter filter = SoloFilePathFilter.wrap(FilePathFilter.current()); if (filter==null) return v; return new FileVisitor() { @@ -3470,7 +3499,7 @@ public boolean understandsSymlink() { /** * Pass through 'f' after ensuring that we can read that file. */ - private File reading(File f) { + private static File reading(File f) { filterNonNull().read(f); return f; } @@ -3478,7 +3507,7 @@ private File reading(File f) { /** * Pass through 'f' after ensuring that we can access the file attributes. */ - private File stating(File f) { + private static File stating(File f) { filterNonNull().stat(f); return f; } @@ -3486,7 +3515,7 @@ private File stating(File f) { /** * Pass through 'f' after ensuring that we can create that file/dir. */ - private File creating(File f) { + private static File creating(File f) { filterNonNull().create(f); return f; } @@ -3494,7 +3523,7 @@ private File creating(File f) { /** * Pass through 'f' after ensuring that we can write to that file. */ - private File writing(File f) { + private static File writing(File f) { FilePathFilter filter = filterNonNull(); if (!f.exists()) filter.create(f); @@ -3505,7 +3534,7 @@ private File writing(File f) { /** * Pass through 'f' after ensuring that we can create that symlink. */ - private File symlinking(File f) { + private static File symlinking(File f) { FilePathFilter filter = filterNonNull(); if (!f.exists()) filter.create(f); @@ -3516,24 +3545,40 @@ private File symlinking(File f) { /** * Pass through 'f' after ensuring that we can delete that file. */ - private File deleting(File f) { + private static File deleting(File f) { filterNonNull().delete(f); return f; } - private boolean mkdirs(File dir) throws IOException { + /** + * Pass through 'f' after ensuring that we can mkdirs that directory. + */ + private static File mkdirsing(File f) { + filterNonNull().mkdirs(f); + return f; + } + + private static boolean mkdirs(File dir) throws IOException { if (dir.exists()) return false; - filterNonNull().mkdirs(dir); + File reference = dir; + while (reference != null && !reference.exists()) { + filterNonNull().mkdirs(reference); + reference = reference.getParentFile(); + } Files.createDirectories(fileToPath(dir)); return true; } - private File mkdirsE(File dir) throws IOException { + private static File mkdirsE(File dir) throws IOException { if (dir.exists()) { return dir; } - filterNonNull().mkdirs(dir); + File reference = dir; + while (reference != null && !reference.exists()) { + filterNonNull().mkdirs(reference); + reference = reference.getParentFile(); + } return IOUtils.mkdirs(dir); } @@ -3561,7 +3606,7 @@ public Boolean invoke(@NonNull File parentFile, @NonNull VirtualChannel channel) throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath); } - Path parentAbsolutePath = Util.fileToPath(parentFile.getAbsoluteFile()); + Path parentAbsolutePath = Util.fileToPath(stating(parentFile).getAbsoluteFile()); Path parentRealPath; try { if (Functions.isWindows()) { diff --git a/core/src/main/java/jenkins/SoloFilePathFilter.java b/core/src/main/java/jenkins/SoloFilePathFilter.java index 073d222e2d53..079373cf237f 100644 --- a/core/src/main/java/jenkins/SoloFilePathFilter.java +++ b/core/src/main/java/jenkins/SoloFilePathFilter.java @@ -1,8 +1,16 @@ package jenkins; import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.FilePath; +import jenkins.util.SystemProperties; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + import java.io.File; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; /** * Variant of {@link FilePathFilter} that assumes it is the sole actor. @@ -13,6 +21,13 @@ * @author Kohsuke Kawaguchi */ public final class SoloFilePathFilter extends FilePathFilter { + + private static final Logger LOGGER = Logger.getLogger(SoloFilePathFilter.class.getName()); + + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + @Restricted(NoExternalUse.class) + public static /* non-final for Groovy */ boolean REDACT_ERRORS = SystemProperties.getBoolean(SoloFilePathFilter.class.getName() + ".redactErrors", true); + private final FilePathFilter base; private SoloFilePathFilter(FilePathFilter base) { @@ -28,8 +43,17 @@ private SoloFilePathFilter(FilePathFilter base) { } private boolean noFalse(String op, File f, boolean b) { - if (!b) - throw new SecurityException("agent may not " + op + " " + f+"\nSee https://www.jenkins.io/redirect/security-144 for more details"); + if (!b) { + final String detailedMessage = "Agent may not '" + op + "' at '" + f + "'. See https://www.jenkins.io/redirect/security-144 for more information."; + if (REDACT_ERRORS) { + // We may end up trying to access file paths indirectly, e.g. FilePath#listFiles starts in an allowed dir but follows symlinks outside, so do not disclose paths in error message + UUID uuid = UUID.randomUUID(); + LOGGER.log(Level.WARNING, () -> uuid + ": " + detailedMessage); + throw new SecurityException("Agent may not access a file path. See the system log for more details about the error ID '" + uuid + "' and https://www.jenkins.io/redirect/security-144 for more information."); + } else { + throw new SecurityException(detailedMessage); + } + } return true; } @@ -49,12 +73,18 @@ public boolean write(File f) throws SecurityException { @Override public boolean symlink(File f) throws SecurityException { - return noFalse("symlink",f,base.write(normalize(f))); + return noFalse("symlink",f,base.symlink(normalize(f))); } @Override public boolean mkdirs(File f) throws SecurityException { - return noFalse("mkdirs",f,base.mkdirs(normalize(f))); + // mkdirs is special because it could operate on parents of the specified path + File reference = normalize(f); + while (reference != null && !reference.exists()) { + noFalse("mkdirs", f, base.mkdirs(reference)); // Pass f as reference into the error to be vague + reference = reference.getParentFile(); + } + return true; } @Override diff --git a/core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java b/core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java index 36683b9b811a..9788c24ce508 100644 --- a/core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java +++ b/core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java @@ -5,12 +5,16 @@ import hudson.Functions; import hudson.model.Failure; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import jenkins.model.Jenkins; @@ -21,6 +25,9 @@ * @author Kohsuke Kawaguchi */ class FilePathRuleConfig extends ConfigDirectory> { + + private static final Logger LOGGER = Logger.getLogger(FilePathRuleConfig.class.getName()); + FilePathRuleConfig(File file) { super(file); } @@ -40,10 +47,17 @@ protected FilePathRule parse(String line) { line = line.trim(); if (line.isEmpty()) return null; + // TODO This does not support custom build dir configuration (Jenkins#getRawBuildsDir() etc.) line = line.replace("","/builds/"); line = line.replace("","(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9]-[0-9][0-9]-[0-9][0-9]|[0-9]+)"); line = line.replace("","/jobs/.+"); - line = line.replace("","\\Q"+Jenkins.get().getRootDir().getPath()+"\\E"); + final File jenkinsHome = Jenkins.get().getRootDir(); + try { + line = line.replace("","\\Q" + jenkinsHome.getCanonicalPath() + "\\E"); + } catch (IOException e) { + LOGGER.log(Level.WARNING, e, () -> "Failed to determine canonical path to Jenkins home directory, falling back to configured value: " + jenkinsHome.getPath()); + line = line.replace("","\\Q" + jenkinsHome.getPath() + "\\E"); + } // config file is always /-separated even on Windows, so bring it back to \-separation. // This is done in the context of regex, so it has to be \\, which means in the source code it is \\\\ @@ -77,9 +91,11 @@ public boolean checkFileAccess(String op, File path) throws SecurityException { for (FilePathRule rule : get()) { if (rule.op.matches(op)) { if (pathStr==null) { - // do not canonicalize, so that JENKINS_HOME that spans across - // multiple volumes via symlinks can look logically like one unit. - pathStr = path.getPath(); + try { + pathStr = path.getCanonicalPath(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } if (isWindows()) // Windows accepts '/' as separator, but for rule matching we want to normalize for consistent comparison pathStr = pathStr.replace('/','\\'); } diff --git a/test/src/test/java/jenkins/security/Security2455Test.java b/test/src/test/java/jenkins/security/Security2455Test.java new file mode 100644 index 000000000000..362d2eb59f6e --- /dev/null +++ b/test/src/test/java/jenkins/security/Security2455Test.java @@ -0,0 +1,756 @@ +package jenkins.security; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeFalse; +import static org.jvnet.hudson.test.LoggerRule.recorded; + +import hudson.ExtensionList; +import hudson.FilePath; +import hudson.Functions; +import hudson.Util; +import hudson.model.Cause; +import hudson.model.FreeStyleBuild; +import hudson.model.Node; +import hudson.model.TaskListener; +import hudson.remoting.VirtualChannel; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.net.URI; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.logging.Level; +import jenkins.SlaveToMasterFileCallable; +import jenkins.SoloFilePathFilter; +import jenkins.agents.AgentComputerUtil; +import jenkins.security.s2m.AdminWhitelistRule; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.NullOutputStream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.jvnet.hudson.test.FlagRule; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.recipes.LocalData; + +@SuppressWarnings("ThrowableNotThrown") +@Issue("SECURITY-2455") +public class Security2455Test { + + // TODO After merge, reference the class directly + private static final String SECURITY_2428_KILLSWITCH = "jenkins.security.s2m.RunningBuildFilePathFilter.FAIL"; + + @Rule + public final FlagRule flagRule = FlagRule.systemProperty(SECURITY_2428_KILLSWITCH, "false"); + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public LoggerRule logging = new LoggerRule().record(SoloFilePathFilter.class, Level.WARNING); + + @Before + public void setup() { + ExtensionList.lookupSingleton(AdminWhitelistRule.class).setMasterKillSwitch(false); + } + + // -------- + + @Test + @Issue("SECURITY-2427") + public void mkdirsParentsTest() { + final File buildStuff = new File(j.jenkins.getRootDir(), "job/nonexistent/builds/1/foo/bar"); + logging.capture(10); + SecurityException ex = assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkdirsParentsCallable(buildStuff))); + assertThat(logging, recorded(containsString("foo/bar"))); + assertThat(ex.getMessage(), not(containsString("foo/bar"))); // test error redaction + + SoloFilePathFilter.REDACT_ERRORS = false; + try { + SecurityException ex2 = assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkdirsParentsCallable(buildStuff))); + assertThat(ex2.getMessage(), containsString("foo/bar")); // test error redaction + } finally { + SoloFilePathFilter.REDACT_ERRORS = true; + } + } + private static class MkdirsParentsCallable extends MasterToSlaveCallable { + private final File file; + + private MkdirsParentsCallable(File file) { + this.file = file; + } + + @Override + public String call() throws Exception { + toFilePathOnController(this.file).mkdirs(); + return null; + } + } + + // -------- + + @Test + @Issue("SECURITY-2444") + public void testNonCanonicalPath() throws Exception { + assumeFalse(Functions.isWindows()); + final FreeStyleBuild build = j.createFreeStyleProject().scheduleBuild2(0, new Cause.UserIdCause()).waitForStart(); + j.waitForCompletion(build); + final File link = new File(build.getRootDir(), "link"); + final File secrets = new File(j.jenkins.getRootDir(), "secrets/master.key"); + Files.createSymbolicLink(link.toPath(), secrets.toPath()); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ReadToStringCallable(link))); + } + @Test + @Issue("SECURITY-2444") + public void testNonCanonicalPathOnController() throws Exception { + assumeFalse(Functions.isWindows()); + final FreeStyleBuild build = j.createFreeStyleProject().scheduleBuild2(0, new Cause.UserIdCause()).waitForStart(); + j.waitForCompletion(build); + final File link = new File(build.getRootDir(), "link"); + final File secrets = new File(j.jenkins.getRootDir(), "secrets/master.key"); + Files.createSymbolicLink(link.toPath(), secrets.toPath()); + String result = FilePath.localChannel.call(new ReadToStringCallable(link)); + assertEquals(IOUtils.readLines(new FileReader(secrets)).get(0), result); + } + + private static class ReadToStringCallable extends MasterToSlaveCallable { + + final String abs; + + ReadToStringCallable(File link) { + abs = link.getPath(); + } + + @Override + public String call() throws IOException { + FilePath p = toFilePathOnController(new File(abs)); + try { + return p.readToString(); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + } + + // -------- + + @Test + @Issue({"SECURITY-2446", "SECURITY-2531"}) + // $ tar tvf symlink.tar + // lrwxr-xr-x 0 501 20 0 Oct 5 09:50 foo -> ../../../../secrets + @LocalData + public void testUntaringSymlinksFails() throws Exception { + final FreeStyleBuild freeStyleBuild = j.buildAndAssertSuccess(j.createFreeStyleProject()); + final File symlinkTarFile = new File(j.jenkins.getRootDir(), "symlink.tar"); + final File untarTargetFile = new File(freeStyleBuild.getRootDir(), "foo"); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UntarFileCallable(symlinkTarFile, untarTargetFile))); + } + private static final class UntarFileCallable extends MasterToSlaveCallable { + private final File source; + private final File destination; + + private UntarFileCallable(File source, File destination) { + this.source = source; + this.destination = destination; + } + + @Override + public Integer call() throws Exception { + final FilePath sourceFilePath = new FilePath(source); + final FilePath destinationFilePath = toFilePathOnController(destination); + sourceFilePath.untar(destinationFilePath, FilePath.TarCompression.NONE); + return 1; + } + } + + // -------- + + @Test + @Issue("SECURITY-2453") + public void testTarSymlinksThatAreSafe() throws Exception { + assumeFalse(Functions.isWindows()); + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + // We cannot touch the build dir itself + final File innerDir = new File(buildDir, "dir"); + final File innerDir2 = new File(buildDir, "dir2"); + assertTrue(innerDir.mkdirs()); + assertTrue(innerDir2.mkdirs()); + assertTrue(new File(innerDir2, "the-file").createNewFile()); + Util.createSymlink(innerDir, "../dir2", "link", TaskListener.NULL); + assertTrue(new File(innerDir, "link/the-file").exists()); + final int files = invokeOnAgent(new TarCaller(innerDir)); + assertEquals(1, files); + } + @Test + @Issue("SECURITY-2453") + public void testTarSymlinksOutsideAllowedDirs() throws Exception { + assumeFalse(Functions.isWindows()); + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + // We cannot touch the build dir itself + final File innerDir = new File(buildDir, "dir"); + assertTrue(innerDir.mkdirs()); + Util.createSymlink(innerDir, "../../../../../secrets", "secrets-link", TaskListener.NULL); + assertTrue(new File(innerDir, "secrets-link/master.key").exists()); + logging.capture(10); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new TarCaller(innerDir))); + assertThat(logging, recorded(containsString("filepath-filters.d"))); + } + + private static class TarCaller extends MasterToSlaveCallable { + private final File root; + + private TarCaller(File root) { + this.root = root; + } + + @Override + public Integer call() throws Exception { + return toFilePathOnController(root).tar(NullOutputStream.NULL_OUTPUT_STREAM, "**"); + } + } + + // -------- + + @Test + @Issue("SECURITY-2484") + public void zipTest() { + final File secrets = new File(j.jenkins.getRootDir(), "secrets"); + assertTrue(secrets.exists()); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ZipTestCallable(secrets))); + } + @Test + @Issue("SECURITY-2484") + public void zipTestController() throws Exception { + final File secrets = new File(j.jenkins.getRootDir(), "secrets"); + assertTrue(secrets.exists()); + FilePath.localChannel.call(new ZipTestCallable(secrets)); + } + + private static class ZipTestCallable extends MasterToSlaveCallable { + private final File file; + + private ZipTestCallable(File file) { + this.file = file; + } + + @Override + public String call() throws Exception { + final File tmp = File.createTempFile("security2455_", ".zip"); + tmp.deleteOnExit(); + toFilePathOnController(file).zip(new FilePath(tmp)); + return tmp.getName(); + } + } + + // -------- + + @Test + @Issue("SECURITY-2485") + @LocalData + public void unzipRemoteTest() { + final File targetDir = j.jenkins.getRootDir(); + final File source = new File(targetDir, "file.zip"); // in this test, controller and agent are on same FS so this works -- file needs to exist but content should not be read + assertTrue(targetDir.exists()); + final List filesBefore = Arrays.asList(Objects.requireNonNull(targetDir.listFiles())); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UnzipRemoteTestCallable(targetDir, source))); + final List filesAfter = Arrays.asList(Objects.requireNonNull(targetDir.listFiles())); + // We cannot do a direct comparison here because `logs/` appears during execution + assertEquals(filesBefore.size(), filesAfter.stream().filter(it -> !it.getName().equals("logs")).count()); + } + + private static class UnzipRemoteTestCallable extends MasterToSlaveCallable { + private final File destination; + private final File source; + + private UnzipRemoteTestCallable(File destination, File source) { + this.destination = destination; + this.source = source; + } + + @Override + public String call() throws Exception { + FilePath onAgent = new FilePath(source); + onAgent.unzip(toFilePathOnController(destination)); + return null; + } + } + + // -------- + + @Test + @Issue("SECURITY-2485") + public void testCopyRecursiveFromControllerToAgent() { + IOException ex = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CopyRecursiveToFromControllerToAgentCallable(new FilePath(new File(j.jenkins.getRootDir(), "secrets"))))); + assertThat(Objects.requireNonNull(ex).getMessage(), containsString("Unexpected end of ZLIB input stream")); // TODO this used to say "premature", why the change? + } + private static class CopyRecursiveToFromControllerToAgentCallable extends MasterToSlaveCallable { + private final FilePath controllerFilePath; + + private CopyRecursiveToFromControllerToAgentCallable(FilePath controllerFilePath) { + this.controllerFilePath = controllerFilePath; + } + + @Override + public Integer call() throws Exception { + return controllerFilePath.copyRecursiveTo(new FilePath(Files.createTempDirectory("jenkins-test").toFile())); + } + } + + // -------- + + @Test + @Issue("SECURITY-2485") + public void testCopyRecursiveFromAgentToController() { + assertThrowsIOExceptionCausedBy(SecurityException.class, () -> invokeOnAgent(new CopyRecursiveToFromAgentToControllerCallable(new FilePath(new File(j.jenkins.getRootDir(), "secrets"))))); + } + private static class CopyRecursiveToFromAgentToControllerCallable extends MasterToSlaveCallable { + private final FilePath controllerFilePath; + + private CopyRecursiveToFromAgentToControllerCallable(FilePath controllerFilePath) { + this.controllerFilePath = controllerFilePath; + } + + @Override + public Integer call() throws Exception { + final File localPath = Files.createTempDirectory("jenkins-test").toFile(); + assertTrue(new File(localPath, "tmpfile").createNewFile()); + return new FilePath(localPath).copyRecursiveTo(controllerFilePath); + } + } + + // -------- + + @Test + @Issue("SECURITY-2486") + public void testDecoyWrapper() { + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ReadToStringBypassCallable(j.jenkins.getRootDir()))); + } + + private static class ReadToStringBypassCallable extends MasterToSlaveCallable { + private final File file; + + private ReadToStringBypassCallable(File file) { + this.file = file; + } + + @Override + public String call() throws Exception { + final Class readToStringClass = Class.forName("hudson.FilePath$ReadToString"); + final Constructor constructor = readToStringClass.getDeclaredConstructor(); // Used to have FilePath.class from non-static context + constructor.setAccessible(true); + + //FilePath agentFilePath = new FilePath(new File("on agent lol")); // only used for the core code before fix + + final SlaveToMasterFileCallable callable = (SlaveToMasterFileCallable) constructor.newInstance(); // agentFilePath + + FilePath controllerFilePath = toFilePathOnController(new File(file, "secrets/master.key")); + final Object returned = controllerFilePath.act(callable); + return (String) returned; + } + } + + // -------- + + @Test + @Issue("SECURITY-2531") + public void testSymlinkCheck() throws Exception { + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new SymlinkCreator(buildDir))); + } + private static class SymlinkCreator extends MasterToSlaveCallable { + private final File baseDir; + private SymlinkCreator(File baseDir) { + this.baseDir = baseDir; + } + + @Override + public String call() throws Exception { + toFilePathOnController(new File(baseDir, "child")).symlinkTo(baseDir.getPath() + "child2", TaskListener.NULL); + return null; + } + } + + // -------- + + // -------- + + @Test + @Issue("SECURITY-2538") // SECURITY-2538 adjacent, confirms that reading, stating etc. is supposed to be possible for userContent + public void testReadUserContent() throws Exception { + invokeOnAgent(new UserContentReader(new File(j.jenkins.getRootDir(), "userContent"))); + } + private static class UserContentReader extends MasterToSlaveCallable { + private final File userContent; + + private UserContentReader(File userContent) { + this.userContent = userContent; + } + + @Override + public String call() throws Exception { + final FilePath userContentFilePath = toFilePathOnController(userContent); + userContentFilePath.lastModified(); + userContentFilePath.zip(NullOutputStream.NULL_OUTPUT_STREAM); + assertThat(userContentFilePath.child("readme.txt").readToString(), containsString(hudson.model.Messages.Hudson_USER_CONTENT_README())); + return null; + } + } + + @Test + @Issue("SECURITY-2538") + public void testRenameTo() throws Exception { + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + final File userContentDir = new File(j.jenkins.getRootDir(), "userContent"); + final File readme = new File(userContentDir, "readme.txt"); + final File to = new File(buildDir, "readme.txt"); + assertTrue("readme.txt is a file", readme.isFile()); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new RenameToCaller(readme, to))); + assertTrue("readme.txt is still a file", readme.isFile()); + assertFalse("to does not exist", to.exists()); + } + private static class RenameToCaller extends MasterToSlaveCallable { + private final File from; + private final File to; + + private RenameToCaller(File from, File to) { + this.from = from; + this.to = to; + } + + @Override + public String call() throws Exception { + toFilePathOnController(from).renameTo(toFilePathOnController(to)); + return null; + } + } + + @Test + @Issue("SECURITY-2538") + public void testMoveChildren() throws Exception { + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + final File userContentDir = new File(j.jenkins.getRootDir(), "userContent"); + // The implementation of MoveAllChildrenTo seems odd and ends up removing the source directory, so work only in subdir of userContent + final File userContentSubDir = new File(userContentDir, "stuff"); + assertTrue(userContentSubDir.mkdirs()); + final File userContentSubDirFileA = new File(userContentSubDir, "fileA"); + final File userContentSubDirFileB = new File(userContentSubDir, "fileB"); + assertTrue(userContentSubDirFileA.createNewFile()); + assertTrue(userContentSubDirFileB.createNewFile()); + assertTrue("userContentSubDir is a directory", userContentSubDir.isDirectory()); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MoveAllChildrenToCaller(userContentSubDir, buildDir))); + assertTrue("userContentSubDir is a directory", userContentSubDir.isDirectory()); + assertFalse("no fileA in buildDir", new File(buildDir, "fileA").exists()); + assertFalse("no fileB in buildDir", new File(buildDir, "fileB").exists()); + assertTrue("fileA is still a file", userContentSubDirFileA.isFile()); + assertTrue("fileB is still a file", userContentSubDirFileB.isFile()); + } + private static class MoveAllChildrenToCaller extends MasterToSlaveCallable { + private final File from; + private final File to; + + private MoveAllChildrenToCaller(File from, File to) { + this.from = from; + this.to = to; + } + + @Override + public String call() throws Exception { + toFilePathOnController(from).moveAllChildrenTo(toFilePathOnController(to)); + return null; + } + } + + // -------- + + @Test + @Issue("SECURITY-2539") + public void testCreateTempFile() { + final File rootDir = j.jenkins.getRootDir(); + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + + // Extra level of catch/throw + logging.capture(10); + final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTempFileCaller(rootDir))); + assertNotNull(ioException); + final Throwable cause = ioException.getCause(); + assertNotNull(cause); + assertTrue(cause instanceof SecurityException); + assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted + assertThat(logging, recorded(containsString("'create'"))); + assertThat(logging, recorded(containsString("/prefix-security-check-dummy-suffix"))); + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + } + private static class CreateTempFileCaller extends MasterToSlaveCallable { + private final File baseDir; + + private CreateTempFileCaller(File baseDir) { + this.baseDir = baseDir; + } + + @Override + public String call() throws Exception { + toFilePathOnController(baseDir).createTempFile("prefix", "suffix"); + return null; + } + } + + + @Test + @Issue("SECURITY-2539") + public void testCreateTextTempFile() { + final File rootDir = j.jenkins.getRootDir(); + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + + // Extra level of catch/throw + logging.capture(10); + final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTextTempFileCaller(rootDir))); + assertNotNull(ioException); + final Throwable cause = ioException.getCause(); + assertNotNull(cause); + assertTrue(cause instanceof SecurityException); + assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted + assertThat(logging, recorded(containsString("'create'"))); + assertThat(logging, recorded(containsString("/prefix-security-check-dummy-suffix"))); + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + } + private static class CreateTextTempFileCaller extends MasterToSlaveCallable { + private final File baseDir; + + private CreateTextTempFileCaller(File baseDir) { + this.baseDir = baseDir; + } + + @Override + public String call() throws Exception { + toFilePathOnController(baseDir).createTextTempFile("prefix", "suffix", "content"); + return null; + } + } + + @Test + @Issue("SECURITY-2539") + public void testCreateTempDir() { + final File rootDir = j.jenkins.getRootDir(); + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + + // Extra level of catch/throw + logging.capture(10); + final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTempDirCaller(rootDir))); + assertNotNull(ioException); + final Throwable cause = ioException.getCause(); + assertNotNull(cause); + assertTrue(cause instanceof SecurityException); + assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted + assertThat(logging, recorded(containsString("'mkdirs'"))); + assertThat(logging, recorded(containsString("/prefix.suffix-security-test"))); // weird but that's what it looks like + assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix"))); + } + private static class CreateTempDirCaller extends MasterToSlaveCallable { + private final File baseDir; + + private CreateTempDirCaller(File baseDir) { + this.baseDir = baseDir; + } + + @Override + public String call() throws Exception { + toFilePathOnController(baseDir).createTempDir("prefix", "suffix"); + return null; + } + } + // -------- + + @Test + @Issue("SECURITY-2541") + public void testStatStuff() { + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ToURICaller(j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new FreeDiskSpaceCaller(j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new TotalDiskSpaceCaller(j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UsableDiskSpaceCaller(j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new AbsolutizeCaller(j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new HasSymlinkCaller(new File (j.jenkins.getRootDir(), "secrets"), j.jenkins.getRootDir()))); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new IsDescendantCaller(j.jenkins.getRootDir(), "secrets"))); + } + + private static class ToURICaller extends MasterToSlaveCallable { + private final File file; + + private ToURICaller(File file) { + this.file = file; + } + + @Override + public URI call() throws Exception { + return toFilePathOnController(file).toURI(); + } + } + private static class AbsolutizeCaller extends MasterToSlaveCallable { + private final File file; + + private AbsolutizeCaller(File file) { + this.file = file; + } + + @Override + public String call() throws Exception { + return toFilePathOnController(file).absolutize().getRemote(); + } + } + private static class HasSymlinkCaller extends MasterToSlaveCallable { + private final File file; + private final File root; + + private HasSymlinkCaller(File file, File root) { + this.file = file; + this.root = root; + } + + @Override + public Boolean call() throws Exception { + return toFilePathOnController(file).hasSymlink(toFilePathOnController(root), false); + } + } + private static class IsDescendantCaller extends MasterToSlaveCallable { + private final File file; + private final String childPath; + + private IsDescendantCaller(File file, String childPath) { + this.file = file; + this.childPath = childPath; + } + + @Override + public Boolean call() throws Exception { + return toFilePathOnController(file).isDescendant(childPath); + } + } + private static class FreeDiskSpaceCaller extends MasterToSlaveCallable { + private final File file; + + private FreeDiskSpaceCaller(File file) { + this.file = file; + } + + @Override + public Long call() throws Exception { + return toFilePathOnController(file).getFreeDiskSpace(); + } + } + private static class UsableDiskSpaceCaller extends MasterToSlaveCallable { + private final File file; + + private UsableDiskSpaceCaller(File file) { + this.file = file; + } + + @Override + public Long call() throws Exception { + return toFilePathOnController(file).getUsableDiskSpace(); + } + } + private static class TotalDiskSpaceCaller extends MasterToSlaveCallable { + private final File file; + + private TotalDiskSpaceCaller(File file) { + this.file = file; + } + + @Override + public Long call() throws Exception { + return toFilePathOnController(file).getTotalDiskSpace(); + } + } + + // -------- + + @Test + @Issue("SECURITY-2542") // adjacent, this confirms we follow symlinks when it's within allowed directories + public void testGlobFollowsSymlinks() throws Exception { + assumeFalse(Functions.isWindows()); + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + // We cannot touch the build dir itself + final File innerDir = new File(buildDir, "dir"); + final File innerDir2 = new File(buildDir, "dir2"); + assertTrue(innerDir.mkdirs()); + assertTrue(innerDir2.mkdirs()); + assertTrue(new File(innerDir2, "the-file").createNewFile()); + Util.createSymlink(innerDir, "../dir2", "link", TaskListener.NULL); + assertTrue(new File(innerDir, "link/the-file").exists()); + final int files = invokeOnAgent(new GlobCaller(innerDir)); + assertEquals(1, files); + } + @Test + @Issue("SECURITY-2542") + public void testGlobSymlinksThrowsOutsideAllowedDirectories() throws Exception { + assumeFalse(Functions.isWindows()); + final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir(); + // We cannot touch the build dir itself + final File innerDir = new File(buildDir, "dir"); + assertTrue(innerDir.mkdirs()); + Util.createSymlink(innerDir, "../../../../../secrets", "secrets-link", TaskListener.NULL); + assertTrue(new File(innerDir, "secrets-link/master.key").exists()); + assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new GlobCaller(innerDir))); + } + private static class GlobCaller extends MasterToSlaveCallable { + private final File root; + + private GlobCaller(File root) { + this.root = root; + } + + @Override + public Integer call() throws Exception { + return toFilePathOnController(root).list("**/*", "", false).length; + } + } + + // -------- + + // Utility functions + + protected static FilePath toFilePathOnController(File file) { + return toFilePathOnController(file.getPath()); + } + + protected static FilePath toFilePathOnController(String path) { + final VirtualChannel channel = AgentComputerUtil.getChannelToMaster(); + return new FilePath(channel, path); + } + + protected T invokeOnAgent(MasterToSlaveCallable callable) throws Exception, X { + final Node agent = j.createOnlineSlave(); + return Objects.requireNonNull(agent.getChannel()).call(callable); + } + + private static SecurityException assertThrowsIOExceptionCausedBySecurityException(ThrowingRunnable runnable) { + return assertThrowsIOExceptionCausedBy(SecurityException.class, runnable); + } + + private static X assertThrowsIOExceptionCausedBy(Class causeClass, ThrowingRunnable runnable) { + try { + runnable.run(); + } catch (IOException ex) { + final Throwable cause = ex.getCause(); + assertTrue("IOException with message: '" + ex.getMessage() + "' wasn't caused by " + causeClass + ": " + (cause == null ? "(null)" : (cause.getClass().getName() + ": " + cause.getMessage())), + cause != null && causeClass.isAssignableFrom(cause.getClass())); + return causeClass.cast(cause); + } catch (Throwable t) { + fail("Threw other Throwable: " + t.getClass() + " with message " + t.getMessage()); + } + fail("Expected exception but passed"); + return null; + } +} diff --git a/test/src/test/java/jenkins/security/s2m/AdminFilePathFilterTest.java b/test/src/test/java/jenkins/security/s2m/AdminFilePathFilterTest.java index fb98068af1b8..4c4d8f41a29a 100644 --- a/test/src/test/java/jenkins/security/s2m/AdminFilePathFilterTest.java +++ b/test/src/test/java/jenkins/security/s2m/AdminFilePathFilterTest.java @@ -24,10 +24,13 @@ package jenkins.security.s2m; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.jvnet.hudson.test.LoggerRule.recorded; import hudson.FilePath; import hudson.model.Slave; @@ -37,19 +40,25 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.lang.reflect.Field; +import java.util.logging.Level; import javax.inject.Inject; +import jenkins.SoloFilePathFilter; import org.jenkinsci.remoting.RoleChecker; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; public class AdminFilePathFilterTest { @Rule public JenkinsRule r = new JenkinsRule(); + @Rule + public LoggerRule logging = new LoggerRule().record(SoloFilePathFilter.class, Level.WARNING); + @Inject AdminWhitelistRule rule; @@ -186,6 +195,7 @@ private void checkSlave_can_readFile(Slave s, FilePath target) throws Exception private void checkSlave_cannot_readFile(Slave s, FilePath target) throws Exception { try { + logging.capture(10); s.getChannel().call(new ReadFileS2MCallable(target)); fail("Slave should not be able to read file in " + target.getRemote()); } catch (IOException e){ @@ -194,7 +204,9 @@ private void checkSlave_cannot_readFile(Slave s, FilePath target) throws Excepti SecurityException se = (SecurityException) t; StringWriter sw = new StringWriter(); se.printStackTrace(new PrintWriter(sw)); - assertTrue(sw.toString().contains("agent may not read")); + assertTrue(sw.toString().contains("Agent may not access a file path")); + + assertThat(logging, recorded(containsString("Agent may not 'read' at"))); } } diff --git a/test/src/test/resources/jenkins/security/Security2455Test/symlink.tar b/test/src/test/resources/jenkins/security/Security2455Test/symlink.tar new file mode 100644 index 0000000000000000000000000000000000000000..de6a1d990b05179c922b72cbd1a5fa9970c718c1 GIT binary patch literal 1536 zcmYex&u5@DFfcGMH#JpY0MTX;+Q7&J%m)gAfr6olp^>?PfuXsHk%EDtk%^H3gMyKs xo<1BErzRJrmK0Ont);~!iA6xCQED*MA0STxfdZw*jp`o_fzc2c4FOt*008eR6fVi}Rn}Lz#D|A62_JlY z!aneabp$jpO^{$#7m#Kwloe?HAuZ9t)TgNE7@+2HoJm2-z;v&3=dXI}Xq@o8 zdfHP*PgB>=*V9wSGn9{mP1EO;zcAkOZIGTXnzIB(NM-0$)AQuLBGct)VqXr7h xU!XvNfhCO~7B&xH2_keuku3xT2@EW03