Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JENKINS-46795] TrustworthyBuild extension point #180

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/main/java/jenkins/scm/api/SCMSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
public abstract class SCMSource extends AbstractDescribableImpl<SCMSource>
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
Expand Down Expand Up @@ -956,13 +958,37 @@ 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)
throws IOException, InterruptedException {
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 {
if (ExtensionList.lookup(TrustworthyBuild.class).stream().anyMatch(tb -> tb.shouldBeTrusted(build, listener))) {
LOGGER.fine(() -> build + " with " + build.getCauses() + " was considered trustworthy, so using " + revision + " as is");
return revision;
} else {
SCMRevision trustedRevision = getTrustedRevision(revision, listener);
if (trustedRevision.equals(revision)) {
LOGGER.fine(() -> build + " was not considered trustworthy, but " + revision + " was trusted anyway");
jglick marked this conversation as resolved.
Show resolved Hide resolved
} else {
LOGGER.fine(() -> build + " was not considered trustworthy, so replacing " + revision + " with " + trustedRevision);
}
return trustedRevision;
}
}

/**
* Turns a possibly {@code null} {@link TaskListener} reference into a guaranteed non-null reference.
*
Expand Down
63 changes: 63 additions & 0 deletions src/main/java/jenkins/scm/api/TrustworthyBuild.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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.Cause;
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.
*/
boolean shouldBeTrusted(@NonNull Run<?, ?> build, @NonNull TaskListener listener);

/**
* Convenience for the common case that a particular trigger cause indicates trust.
* Examples of causes which should <em>not</em> be registered include:
* <ul>
* <li>{@link TimerTrigger.TimerTriggerCause}
* <li>{@link SCMTrigger.SCMTriggerCause}
* <li>{@code BranchIndexingCause}
* <li>{@code BranchEventCause}
* </ul>
*/
static TrustworthyBuild byCause(Class<? extends Cause> causeType) {
return (build, listener) -> build.getCause(causeType) != null;
}

}
84 changes: 84 additions & 0 deletions src/main/java/jenkins/scm/impl/TrustworthyBuilds.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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);
Copy link
Member

@daniel-beck daniel-beck Apr 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it should be rare, there can be multiple causes of the same type for a build that aren't collapsed into one entry. Whether a build is approved or not could depend on the (insertion) order of the CauseAction#causeBag.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there can be multiple causes of the same type for a build that aren't collapsed into one entry

The API does not prevent it but this ought never happen—it is the responsible of code triggering the build to pass at most one Cause of any given type. At worst a build is not considered trusted when it could have been, so this does not seem like a problem in practice.

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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could pass true but we expect the User to have just been loaded anyway.

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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be defined more modularly as an extension in workflow-cps, but would be awkward since the rest of this impl would need to be duplicated, and ordinal used to avoid printing misleading messages.

In practice the callers of getTrustedRevisionForBuild are going to be in Pipeline code so I am not too concerned.

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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Extension
public static TrustworthyBuild byGitHubChecks() {
return (build, listener) -> build.getCauses().stream().anyMatch(
cause -> cause.getClass().getName().equals("io.jenkins.plugins.checks.github.CheckRunGHEventSubscriber$GitHubChecksRerunActionCause"));
}

private TrustworthyBuilds() {}

}