diff --git a/src/main/java/jenkins/scm/api/SCMSource.java b/src/main/java/jenkins/scm/api/SCMSource.java index 58124718..fa826374 100644 --- a/src/main/java/jenkins/scm/api/SCMSource.java +++ b/src/main/java/jenkins/scm/api/SCMSource.java @@ -40,7 +40,9 @@ import hudson.scm.SCM; import hudson.util.AlternativeUiTextProvider; import hudson.util.LogTaskListener; +import hudson.util.StreamTaskListener; import java.io.IOException; +import java.io.StringWriter; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; @@ -76,6 +78,8 @@ public abstract class SCMSource extends AbstractDescribableImpl implements ExtensionPoint { + private static final Logger LOGGER = Logger.getLogger(SCMSource.class.getName()); + /** * Replaceable pronoun of that points to a {@link SCMSource}. Defaults to {@code null} depending on the context. * @since 2.0 @@ -955,6 +959,7 @@ public final SCM build(@NonNull SCMHead head) { * @throws IOException in case the implementation must call {@link #fetch(SCMHead, TaskListener)} or similar * @throws InterruptedException in case the implementation must call {@link #fetch(SCMHead, TaskListener)} or similar * @since 1.1 + * @see #getTrustedRevisionForBuild */ @NonNull public SCMRevision getTrustedRevision(@NonNull SCMRevision revision, @NonNull TaskListener listener) @@ -962,6 +967,37 @@ public SCMRevision getTrustedRevision(@NonNull SCMRevision revision, @NonNull Ta return revision; } + /** + * Refined version of {@link #getTrustedRevision(SCMRevision, TaskListener)} that takes into account the build context. + * @param build a running build + * @return {@link #getTrustedRevision} if the build itself does not indicate trust; + * simply {@code revision} if any {@link TrustworthyBuild} says that it does + */ + @NonNull + public final SCMRevision getTrustedRevisionForBuild(@NonNull SCMRevision revision, @NonNull TaskListener listener, @NonNull Run build) + throws IOException, InterruptedException { + // Cheaper to check TrustworthyBuild than to call some getTrustedRevision impls, so try that first, + // but defer printing resulting messages if possible. + StringWriter buffer = new StringWriter(); + TaskListener bufferedListener = new StreamTaskListener(buffer); + if (ExtensionList.lookup(TrustworthyBuild.class).stream().anyMatch(tb -> tb.shouldBeTrusted(build, bufferedListener))) { + LOGGER.fine(() -> build + " with " + build.getCauses() + " was considered trustworthy, so using " + revision + " as is"); + listener.getLogger().print(buffer.toString()); + return revision; + } else { + SCMRevision trustedRevision = getTrustedRevision(revision, listener); + if (trustedRevision.equals(revision)) { // common case + LOGGER.fine(() -> revision + " was trusted anyway so it is irrelevant that " + build + " was not specifically considered trustworthy\n" + buffer); + } else { + LOGGER.fine(() -> build + " was not considered trustworthy, so replacing " + revision + " with " + trustedRevision); + listener.getLogger().print(buffer.toString()); + listener.getLogger().printf("This build was not considered trustworthy, so replacing %s with %s for sensitive files.%n", revision, trustedRevision); + listener.getLogger().printf("(To retest using %s, trigger a new build explicitly, for example using Replay.)%n", revision); + } + return trustedRevision; + } + } + /** * Turns a possibly {@code null} {@link TaskListener} reference into a guaranteed non-null reference. * diff --git a/src/main/java/jenkins/scm/api/TrustworthyBuild.java b/src/main/java/jenkins/scm/api/TrustworthyBuild.java new file mode 100644 index 00000000..26533a59 --- /dev/null +++ b/src/main/java/jenkins/scm/api/TrustworthyBuild.java @@ -0,0 +1,55 @@ +/* + * The MIT License + * + * Copyright 2022 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.scm.api; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.ExtensionPoint; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.triggers.SCMTrigger; +import hudson.triggers.TimerTrigger; + +/** + * Allows plugins to declare that builds were triggered deliberately. + * This allows an authorized user to run CI on (say) a pull request filed by an outsider, + * having confirmed that there is nothing malicious about the contents. + * @see SCMSource#getTrustedRevisionForBuild + */ +public interface TrustworthyBuild extends ExtensionPoint { + + /** + * Should this build be trusted to load sensitive source files? + * If any implementation returns true then it is trusted. + * Examples of build-triggering causes which should not be trusted include: + * + */ + boolean shouldBeTrusted(@NonNull Run build, @NonNull TaskListener listener); + +} diff --git a/src/main/java/jenkins/scm/impl/TrustworthyBuilds.java b/src/main/java/jenkins/scm/impl/TrustworthyBuilds.java new file mode 100644 index 00000000..9661d806 --- /dev/null +++ b/src/main/java/jenkins/scm/impl/TrustworthyBuilds.java @@ -0,0 +1,91 @@ +/* + * The MIT License + * + * Copyright 2022 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.scm.impl; + +import hudson.Extension; +import hudson.model.Cause; +import hudson.model.Item; +import hudson.model.Run; +import hudson.model.User; +import jenkins.scm.api.TrustworthyBuild; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +public class TrustworthyBuilds { + + // Also effectively handles ReplayCause since that is only ever added in conjunction with UserIdCause. (see ReplayAction.run2) + @Extension + public static TrustworthyBuild byUserId() { + return (build, listener) -> { + var cause = build.getCause(Cause.UserIdCause.class); + if (cause == null) { + // probably some other cause; do not print anything + return false; + } + var userId = cause.getUserId(); + if (userId == null) { + listener.getLogger().println("Not trusting build since no user name was recorded"); + return false; + } + var user = User.getById(userId, false); + if (user == null) { + listener.getLogger().printf("Not trusting build since no user ‘%s’ is known%n", userId); + return false; + } + try { + var permission = Run.PERMISSIONS.find("Replay"); // ReplayAction.REPLAY + if (permission == null) { // no workflow-cps + permission = Item.CONFIGURE; + } + if (build.hasPermission2(user.impersonate2(), permission)) { + listener.getLogger().printf("Trusting build since it was started by user ‘%s’%n", userId); + return true; + } else { + listener.getLogger().printf("Not trusting build since user ‘%s’ lacks %s/%s permission%n", userId, permission.group.title, permission.name); + return false; + } + } catch (UsernameNotFoundException x) { + listener.getLogger().printf("Not trusting build since user ‘%s’ is invalid%n", userId); + return false; + } + }; + } + + // TODO until github-checks can declare a dep on a sufficiently new scm-api + @Extension + public static TrustworthyBuild byGitHubChecks() { + return (build, listener) -> { + for (Cause cause : build.getCauses()) { + if (cause.getClass().getName().equals("io.jenkins.plugins.checks.github.CheckRunGHEventSubscriber$GitHubChecksRerunActionCause")) { + listener.getLogger().println("Trusting build since it was a rerun request through GitHub checks API"); + return true; + } + } + return false; + }; + } + + private TrustworthyBuilds() {} + +}