lines = LogRedactor.patternLines(value);
+ for (int i = 0; i < lines.size(); i++) {
+ try {
+ Pattern.compile(lines.get(i));
+ } catch (PatternSyntaxException e) {
+ return FormValidation.error("Pattern " + (i + 1) + " (" + lines.get(i)
+ + ") is not a valid regular expression: " + e.getDescription());
+ }
+ }
+ return FormValidation.ok();
+ }
+
@POST
public FormValidation doCheckAutoExplainMaxPerHour(@QueryParameter int value) {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
diff --git a/src/main/java/io/jenkins/plugins/explain_error/LogRedactor.java b/src/main/java/io/jenkins/plugins/explain_error/LogRedactor.java
new file mode 100644
index 00000000..5a111b65
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/explain_error/LogRedactor.java
@@ -0,0 +1,575 @@
+package io.jenkins.plugins.explain_error;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * Masks secrets in the text sent to the AI provider: build logs, custom context and workspace files.
+ *
+ * Jenkins already masks the credentials a build binds with {@code withCredentials}, but a log also shows
+ * secrets that never went through a credential: a token in an environment dump, a password in a URL, a key
+ * printed by a script. The built-in rules match the shape of common secrets; an administrator adds rules of
+ * their own. A rule with a group named {@code secret} masks that group only, so that the model still sees
+ * what the value was for ({@code password=[REDACTED]}); any other rule masks the whole match.
+ *
+ * Anyone who can write to a build log decides what these rules run on, so every built-in rule takes time in
+ * proportion to the length of the text: no quantifier there can go back over what it took more than a bounded
+ * number of characters.
+ *
+ * One instance serves one request and counts what it masked across every text it was given.
+ */
+final class LogRedactor {
+
+ static final String MASK = "[REDACTED]";
+
+ private static final Logger LOGGER = Logger.getLogger(LogRedactor.class.getName());
+
+ /** A value already masked, by Jenkins ({@code ****}) or by an earlier rule, is not masked again. */
+ private static final String NOT_MASKED = "(?!\\[REDACTED\\]|\\*{3,}+(?:[\\s\"',;]|$))";
+
+ /** A reference to a value rather than the value: {@code ${PASSWORD}}, {@code %s}, {@code {{ token }}}. */
+ private static final String NOT_A_REFERENCE = "(?![$%{])";
+
+ /** What ends a line in a log, or inside a JSON string. */
+ private static final String LINE_BREAK = "(?:\\\\[nr]|\\s)++";
+
+ private static final String KEY_HEADER = "PRIVATE KEY(?: BLOCK)?-----";
+
+ /** The first line of a private key. */
+ private static final Pattern PRIVATE_KEY_BEGIN = Pattern.compile("-----BEGIN [A-Z0-9 ]{0,40}" + KEY_HEADER);
+
+ /** The last line of a private key. */
+ private static final Pattern PRIVATE_KEY_END = Pattern.compile("-----END [A-Z0-9 ]{0,40}" + KEY_HEADER);
+
+ /**
+ * What follows the first line of a key whose last line is not near: its base64 lines and headers, each a
+ * line of its own, then its last line if it is there. A key cut short is masked as far as its base64 goes.
+ */
+ private static final Pattern PRIVATE_KEY_BODY = Pattern.compile("(?:" + LINE_BREAK
+ + "(?:(?:[A-Za-z0-9+/=]|\\\\/){16,}+(?=[ \\t]*+(?:[\\r\\n\"']|\\\\[nr]|$))"
+ + "|(?:Proc-Type|DEK-Info|Version|Comment|Hash):[^\\r\\n\\\\]*+))*+"
+ + "(?:" + LINE_BREAK + "(?:[A-Za-z0-9+/=]{1,15}+" + LINE_BREAK + ")?-----END [A-Z0-9 ]{0,40}"
+ + KEY_HEADER + ")?+");
+
+ /**
+ * The most characters from the first line of a key to its last. A PGP key with subkeys, its lines each
+ * behind a timestamp, fits; a last line further away belongs to some other key.
+ */
+ private static final int MAX_KEY_LENGTH = 32 * 1024;
+
+ /** Secrets recognised by their shape alone. */
+ private static final List TOKENS = List.of(
+ // AWS access key IDs
+ Pattern.compile("\\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\\b"),
+ // GitHub tokens: personal, OAuth, app, refresh, fine-grained
+ Pattern.compile("\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\\b"),
+ // GitLab personal, project and group access tokens
+ Pattern.compile("\\bglpat-[A-Za-z0-9_-]{20,}+"),
+ // Slack tokens
+ Pattern.compile("\\bxox[abposr]-[A-Za-z0-9-]{10,}+"),
+ // Google API keys
+ Pattern.compile("\\bAIza[0-9A-Za-z_-]{35}\\b"),
+ // Stripe live keys
+ Pattern.compile("\\b[rs]k_live_[0-9A-Za-z]{16,}+"),
+ // OpenAI and Anthropic API keys: a digit somewhere, which a word joined with dashes has not
+ Pattern.compile("\\bsk-(?=[A-Za-z_-]{0,60}+\\d)(?:proj-|svcacct-|admin-|ant-(?:[a-z]{3}\\d\\d-)?)?"
+ + "[A-Za-z0-9_-]{20,}+"),
+ // npm access tokens
+ Pattern.compile("\\bnpm_[A-Za-z0-9]{36}\\b"),
+ // JSON Web Tokens: a header and a payload, both base64url JSON, and a signature
+ Pattern.compile("\\beyJ[A-Za-z0-9_-]{8,}+\\.eyJ[A-Za-z0-9_-]{8,}+\\.[A-Za-z0-9_-]{8,}+"),
+ // The credentials of an Authorization header, or of a bearer token on its own; a token has a digit
+ // somewhere, and "Bearer authentication failed" has none
+ Pattern.compile("(?i)\\b(?:authorization[ \\t]*+[:=][ \\t]*+(?:bearer|basic|token)|bearer)[ \\t]++"
+ + NOT_MASKED + "(?=[A-Za-z._~+/-]{0,100}+\\d)(?[A-Za-z0-9._~+/-]{8,}+=*+)"),
+ // The password of a URL: https://user:password@host
+ Pattern.compile("(?i)(?[^\\s/@]{1,200}+)@"),
+ // A command-line option named like a secret, with its value quoted: --password 'p@ss w0rd'
+ Pattern.compile("(?i)(?[\"'])" + NOT_MASKED + NOT_A_REFERENCE
+ + "(?(?:(?!\\k)[^\\r\\n]){1,200}+)\\k"),
+ // ... and without quotes: --password hunter2, --token=...
+ Pattern.compile("(?i)(?[^\\s\"'-][^\\s\"']{3,}+)"));
+
+ /**
+ * The password of {@code -u user:password}, masked only after {@code curl} on the same line: docker and sudo
+ * take {@code -u user:group}, which is not secret.
+ */
+ private static final Pattern USER_OPTION = Pattern.compile("(?[^\\s\"']{1,200}+)");
+
+ /**
+ * A key given a value: {@code KEY=}, {@code key: }, {@code "key": "}, also in a JSON string
+ * ({@code \"key\":\"}) and after {@code -D}. Whether the key names a secret is decided by
+ * {@link #isSecretKey}, which a regular expression could only do by trying every part of the key; only then is
+ * a value looked for after it, so that what follows a key that does not name a secret is searched for keys in
+ * turn.
+ */
+ private static final Pattern KEY = Pattern.compile("(?[A-Za-z_][A-Za-z0-9_.-]{0,59}+)(?:\\\\?[\"'])?[ \\t]{0,3}+(?[:=])[ \\t]{0,3}+"
+ + "(?\\\\?[\"'])?");
+
+ /** The value after a {@link #KEY}. */
+ private static final Pattern VALUE = Pattern.compile(NOT_MASKED + NOT_A_REFERENCE + "[^\\s\"'\\\\,;]{4,}+");
+
+ /** The value after a {@link #KEY} in a query string, which ends at the next parameter. */
+ private static final Pattern QUERY_VALUE = Pattern.compile(NOT_MASKED + NOT_A_REFERENCE
+ + "[^\\s\"'\\\\,;]{4,}+");
+
+ /** Values that say there is none: {@code password=null} tells why something failed. */
+ private static final Pattern NO_VALUE = Pattern.compile("(?i)null|undefined|none|nil|empty|true|false");
+
+ /** An XML element with a value: {@code ...}, as in Maven's settings.xml. */
+ private static final Pattern XML_ELEMENT = Pattern.compile("<(?[A-Za-z_][A-Za-z0-9_.:-]{0,59}+)"
+ + "(?:[ \\t][^<>\\r\\n]{0,200}+)?>[ \\t]*+" + NOT_MASKED + NOT_A_REFERENCE
+ + "(?[^<\\r\\n]{1,500}+)\\k>");
+
+ /** Words that name a secret when they end a key: {@code DB_PASSWORD}, {@code clientSecret}. */
+ private static final Set SECRET_WORDS = Set.of("password", "passwd", "passphrase", "secret", "token",
+ "apikey", "accesskey", "privatekey", "secretkey");
+
+ /** Words that end a key written in capitals as one word: {@code PGPASSWORD}, {@code NPMTOKEN}. */
+ private static final List SECRET_ENDINGS = List.of("password", "passwd", "passphrase", "secret",
+ "token");
+
+ /** Words that name a secret before {@code key}: {@code API_KEY}, {@code AccountKey}, {@code private-key}. */
+ private static final Set BEFORE_KEY = Set.of("api", "access", "private", "secret", "account", "auth",
+ "signing", "encryption", "master", "client", "license");
+
+ /** A word that may follow the ones above and still name the secret itself: {@code SECRET_KEY_BASE}. */
+ private static final Set AFTER_SECRET = Set.of("value", "base", "b64", "base64");
+
+ /** Where a key name divides into words: separators, and where camel case starts a word. */
+ private static final Pattern WORD_BREAK =
+ Pattern.compile("[_.:-]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])");
+
+ private static final Pattern LETTERS = Pattern.compile("[A-Za-z]++");
+
+ private final boolean builtIn;
+ private final List patterns;
+ private int count;
+
+ /**
+ * @param builtIn whether the built-in rules apply
+ * @param patterns rules of the administrator's own
+ */
+ LogRedactor(boolean builtIn, List patterns) {
+ this.builtIn = builtIn;
+ this.patterns = List.copyOf(patterns);
+ }
+
+ /** The built-in rules alone. */
+ static LogRedactor builtIn() {
+ return new LogRedactor(true, List.of());
+ }
+
+ /** The rules the global configuration asks for: the built-in ones if on, then the administrator's own. */
+ @NonNull
+ static LogRedactor fromConfiguration() {
+ GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
+ List patterns = new ArrayList<>();
+ for (String line : patternLines(config.getRedactionPatterns())) {
+ try {
+ patterns.add(Pattern.compile(line));
+ } catch (PatternSyntaxException e) {
+ // The form reports it; a pattern saved some other way is left out rather than stop explanations
+ LOGGER.fine("A redaction pattern is not a valid regular expression, so it is not used: "
+ + e.getDescription());
+ }
+ }
+ return new LogRedactor(config.isRedactSecrets(), patterns);
+ }
+
+ /** The patterns of the setting: one per line, leaving out blank lines and those starting with {@code #}. */
+ static List patternLines(@CheckForNull String patterns) {
+ List lines = new ArrayList<>();
+ if (patterns == null) {
+ return lines;
+ }
+ for (String line : patterns.split("\\R")) {
+ String trimmed = line.strip();
+ if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
+ lines.add(trimmed);
+ }
+ }
+ return lines;
+ }
+
+ /**
+ * Whether a key names a secret: its last word is one of {@link #SECRET_WORDS}, or {@code key} after one of
+ * {@link #BEFORE_KEY}, maybe followed by one of {@link #AFTER_SECRET}; or, written in capitals, it ends in
+ * one of {@link #SECRET_ENDINGS}. {@code TokenService}, {@code password_reset}, {@code jsonwebtoken},
+ * {@code max_tokens} and {@code DB_PASSWORD_FILE} do not.
+ */
+ static boolean isSecretKey(String key) {
+ List words = new ArrayList<>();
+ for (String word : WORD_BREAK.split(key)) {
+ if (!word.isEmpty()) {
+ words.add(word);
+ }
+ }
+ int last = words.size() - 1;
+ if (last > 0 && AFTER_SECRET.contains(words.get(last).toLowerCase(Locale.ROOT))) {
+ last--;
+ }
+ if (last < 0) {
+ return false;
+ }
+ String original = words.get(last);
+ String word = original.toLowerCase(Locale.ROOT);
+ if (SECRET_WORDS.contains(word)) {
+ return true;
+ }
+ if ("key".equals(word)) {
+ return last > 0 && BEFORE_KEY.contains(words.get(last - 1).toLowerCase(Locale.ROOT));
+ }
+ return original.equals(original.toUpperCase(Locale.ROOT)) && SECRET_ENDINGS.stream().anyMatch(word::endsWith);
+ }
+
+ /**
+ * @return {@code text} with every secret the rules find replaced by {@link #MASK}; {@code null} for
+ * {@code null}
+ */
+ @CheckForNull
+ String redact(@CheckForNull String text) {
+ if (text == null || text.isEmpty() || !isActive()) {
+ return text;
+ }
+ String result = text;
+ if (builtIn) {
+ result = maskPrivateKeys(result);
+ result = maskKeyEnds(result);
+ for (Pattern token : TOKENS) {
+ result = apply(token, result, match -> true);
+ }
+ result = apply(USER_OPTION, result, new AfterCurl(result));
+ result = maskKeyValues(result);
+ result = apply(XML_ELEMENT, result, match -> isSecretKey(match.group("key")));
+ }
+ for (Pattern pattern : patterns) {
+ result = apply(pattern, result, match -> true);
+ }
+ return result;
+ }
+
+ /**
+ * Masks private keys from their first line: to their last line when it comes within {@link #MAX_KEY_LENGTH},
+ * is of the same kind and no other first line comes before it, which also takes in lines that start with a
+ * timestamp or another prefix; else as far as their base64 goes, so that a key header printed on its own, as
+ * a secret scanner does, takes no log lines with it. The next first and last lines found are kept for the next
+ * keys while they are ahead of them, so the text is read once.
+ */
+ private String maskPrivateKeys(String text) {
+ Matcher begin = PRIVATE_KEY_BEGIN.matcher(text);
+ if (!begin.find()) {
+ return text;
+ }
+ Matcher end = PRIVATE_KEY_END.matcher(text);
+ Matcher nextBegin = PRIVATE_KEY_BEGIN.matcher(text);
+ Matcher body = PRIVATE_KEY_BODY.matcher(text);
+ StringBuilder result = new StringBuilder(text.length());
+ int last = 0;
+ int endStart = -1;
+ int endEnd = -1;
+ String endKind = null;
+ boolean moreEnds = true;
+ int nextBeginStart = -1;
+ boolean moreBegins = true;
+ do {
+ if (moreEnds && endStart < begin.end()) {
+ moreEnds = end.find(begin.end());
+ if (moreEnds) {
+ endStart = end.start();
+ endEnd = end.end();
+ endKind = end.group().substring("-----END".length());
+ }
+ }
+ if (moreBegins && nextBeginStart < begin.end()) {
+ moreBegins = nextBegin.find(begin.end());
+ nextBeginStart = moreBegins ? nextBegin.start() : -1;
+ }
+ int stop;
+ if (moreEnds && endStart - begin.end() <= MAX_KEY_LENGTH && (!moreBegins || endStart < nextBeginStart)
+ && begin.group().substring("-----BEGIN".length()).equals(endKind)) {
+ stop = endEnd;
+ } else {
+ body.region(begin.end(), text.length());
+ stop = body.lookingAt() ? body.end() : begin.end();
+ }
+ result.append(text, last, begin.start()).append(MASK);
+ last = stop;
+ count++;
+ } while (last < text.length() && begin.find(last));
+ return result.append(text, last, text.length()).toString();
+ }
+
+ /**
+ * Masks the base64 lines before the last line of a private key whose first line is not in the text, as
+ * when the key starts before the lines sent. Walks back from each last line, so the time stays in
+ * proportion to the length of the text.
+ */
+ private String maskKeyEnds(String text) {
+ Matcher end = PRIVATE_KEY_END.matcher(text);
+ if (!end.find()) {
+ return text;
+ }
+ StringBuilder result = new StringBuilder(text.length());
+ int last = 0;
+ do {
+ int start = keyBodyStart(text, end.start(), last);
+ result.append(text, last, start).append(MASK);
+ last = end.end();
+ count++;
+ } while (end.find());
+ return result.append(text, last, text.length()).toString();
+ }
+
+ /**
+ * Where the base64 lines that end at {@code end} start, going no further back than {@code limit}. Each line
+ * is base64 from its start to its end; the last one may be short.
+ */
+ private static int keyBodyStart(String text, int end, int limit) {
+ int start = end;
+ int position = end;
+ boolean lastLine = true;
+ while (position > limit) {
+ int lineEnd = position;
+ while (lineEnd > limit) {
+ if (Character.isWhitespace(text.charAt(lineEnd - 1))) {
+ lineEnd--;
+ } else if (isEscapedLineBreak(text, lineEnd, limit)) {
+ lineEnd -= 2;
+ } else {
+ break;
+ }
+ }
+ int lineStart = lineEnd;
+ while (lineStart > limit && !isEscapedLineBreak(text, lineStart, limit)) {
+ char c = text.charAt(lineStart - 1);
+ // A slash in a JSON string may be written \/
+ if (isBase64(c) || c == '\\' && lineStart < lineEnd && text.charAt(lineStart) == '/') {
+ lineStart--;
+ } else {
+ break;
+ }
+ }
+ int length = lineEnd - lineStart;
+ if (length == 0 || length < 16 && !lastLine || !isLineStart(text, lineStart, limit)) {
+ break;
+ }
+ start = lineStart;
+ position = lineStart;
+ lastLine = false;
+ }
+ return start;
+ }
+
+ private static boolean isBase64(char c) {
+ return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '+' || c == '/'
+ || c == '=';
+ }
+
+ /** Whether {@code \n} or {@code \r}, as written in a JSON string, ends just before {@code position}. */
+ private static boolean isEscapedLineBreak(String text, int position, int limit) {
+ if (position - 2 < limit || text.charAt(position - 2) != '\\') {
+ return false;
+ }
+ char c = text.charAt(position - 1);
+ return c == 'n' || c == 'r';
+ }
+
+ /** Whether a line starts at {@code position}: after a line break, or after the quote that opens a string. */
+ private static boolean isLineStart(String text, int position, int limit) {
+ if (position == limit) {
+ return true;
+ }
+ char before = text.charAt(position - 1);
+ return before == '\n' || before == '\r' || before == '"' || before == '\''
+ || isEscapedLineBreak(text, position, limit);
+ }
+
+ /**
+ * Masks the values given to keys named like a secret. {@code password=null} and the like are left: they say
+ * there is no value. So is a single word after a colon when the key is a single word in a sentence:
+ * {@code Invalid token: expired} says what went wrong, where {@code password: changeme} at the start of a
+ * line of YAML gives the value.
+ */
+ private String maskKeyValues(String text) {
+ Matcher key = KEY.matcher(text);
+ Matcher value = VALUE.matcher(text);
+ Matcher queryValue = QUERY_VALUE.matcher(text);
+ StringBuilder result = null;
+ int last = 0;
+ int from = 0;
+ while (from < text.length() && key.find(from)) {
+ from = key.end();
+ String name = key.group("key");
+ if (!isSecretKey(name)) {
+ continue;
+ }
+ int keyStart = key.start();
+ boolean inQuery = keyStart > 0 && (text.charAt(keyStart - 1) == '?' || text.charAt(keyStart - 1) == '&');
+ Matcher found = inQuery ? queryValue : value;
+ found.region(key.end(), text.length());
+ if (!found.lookingAt() || NO_VALUE.matcher(found.group()).matches()
+ || ":".equals(key.group("separator")) && key.group("quote") == null
+ && LETTERS.matcher(found.group()).matches() && isInASentence(text, keyStart, name)) {
+ continue;
+ }
+ if (result == null) {
+ result = new StringBuilder(text.length());
+ }
+ result.append(text, last, found.start()).append(MASK);
+ last = found.end();
+ from = found.end();
+ count++;
+ }
+ return result == null ? text : result.append(text, last, text.length()).toString();
+ }
+
+ /**
+ * Whether a key of one word comes after a word, as in {@code ERROR: Invalid token} or
+ * {@code on field 'secret'}, rather than first on its line, after a list or map mark, or after a prefix such
+ * as {@code [INFO]} or a timestamp.
+ */
+ private static boolean isInASentence(String text, int keyStart, String key) {
+ if (!LETTERS.matcher(key).matches()) {
+ return false;
+ }
+ int position = keyStart;
+ if (position > 0 && (text.charAt(position - 1) == '\'' || text.charAt(position - 1) == '"')) {
+ position--;
+ }
+ while (position > 0 && (text.charAt(position - 1) == ' ' || text.charAt(position - 1) == '\t')) {
+ position--;
+ }
+ return position > 0 && Character.isLetter(text.charAt(position - 1));
+ }
+
+ /**
+ * Masks what {@code rule} finds and {@code accept} agrees with: the group named {@code secret} if the rule
+ * has one and it took part in the match, or else the whole match.
+ */
+ private String apply(Pattern rule, String text, Predicate accept) {
+ Matcher matcher = rule.matcher(text);
+ if (!matcher.find()) {
+ return text;
+ }
+ boolean secretGroup = hasSecretGroup(matcher);
+ StringBuilder result = new StringBuilder(text.length());
+ int last = 0;
+ do {
+ if (!accept.test(matcher)) {
+ continue;
+ }
+ int start = matcher.start();
+ int end = matcher.end();
+ if (secretGroup && matcher.start("secret") >= 0) {
+ start = matcher.start("secret");
+ end = matcher.end("secret");
+ }
+ if (end > start) {
+ result.append(text, last, start).append(MASK);
+ last = end;
+ count++;
+ }
+ } while (matcher.find());
+ return result.append(text, last, text.length()).toString();
+ }
+
+ /**
+ * Whether {@code curl} comes before a match in its command: on its line or a line it continues with a
+ * backslash, and not before a pipe, {@code ;} or {@code &&} outside quotes ({@code -H "...; charset=utf-8"}
+ * is one argument). The matches come in order, so the text is read once, as far as the last match.
+ */
+ private static final class AfterCurl implements Predicate {
+
+ private final String text;
+ private int read;
+ private int lineStart;
+ private int curl = -1;
+ /** The quote of the argument being read, or 0 outside quotes. */
+ private char quote;
+
+ AfterCurl(String text) {
+ this.text = text;
+ }
+
+ @Override
+ public boolean test(Matcher match) {
+ for (; read < match.start(); read++) {
+ char c = text.charAt(read);
+ if (c == '\n' || c == '\r') {
+ if (!isContinued(read)) {
+ lineStart = read + 1;
+ quote = 0;
+ }
+ } else if (c == '\'' || c == '"') {
+ if (quote == 0) {
+ quote = c;
+ } else if (quote == c && !(c == '"' && read > 0 && text.charAt(read - 1) == '\\')) {
+ quote = 0;
+ }
+ } else if (quote == 0 && isCommandSeparator(c, read)) {
+ lineStart = read + 1;
+ } else if (c == 'c' && text.startsWith("curl", read) && isWordStart(read) && isWordEnd(read + 4)) {
+ curl = read;
+ }
+ }
+ return curl >= lineStart;
+ }
+
+ /** Whether a pipe, {@code ;} or {@code &&} at {@code position} ends a command. */
+ private boolean isCommandSeparator(char c, int position) {
+ return c == '|' || c == ';' || c == '&' && position + 1 < text.length() && text.charAt(position + 1) == '&';
+ }
+
+ /** Whether the line break at {@code position} follows a backslash, which continues the command. */
+ private boolean isContinued(int position) {
+ int before = position - 1;
+ if (text.charAt(position) == '\n' && before >= 0 && text.charAt(before) == '\r') {
+ before--;
+ }
+ return before >= 0 && text.charAt(before) == '\\';
+ }
+
+ private boolean isWordStart(int position) {
+ return position == 0 || !Character.isLetterOrDigit(text.charAt(position - 1));
+ }
+
+ private boolean isWordEnd(int position) {
+ return position >= text.length() || !Character.isLetterOrDigit(text.charAt(position));
+ }
+ }
+
+ /** Whether the pattern of a matcher that found something has a group named {@code secret}. */
+ private static boolean hasSecretGroup(Matcher matcher) {
+ try {
+ matcher.start("secret");
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /** How many secrets were masked in all the texts given so far. */
+ int count() {
+ return count;
+ }
+
+ /** Whether there is any rule to apply. */
+ boolean isActive() {
+ return builtIn || !patterns.isEmpty();
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/explain_error/UsageEvent.java b/src/main/java/io/jenkins/plugins/explain_error/UsageEvent.java
index 4ca20507..0fcbfb26 100644
--- a/src/main/java/io/jenkins/plugins/explain_error/UsageEvent.java
+++ b/src/main/java/io/jenkins/plugins/explain_error/UsageEvent.java
@@ -47,7 +47,9 @@ public enum Result {
MISCONFIGURED("misconfigured"),
PROVIDER_ERROR("provider_error"),
QUOTA_REJECTED("quota_rejected"),
- THROTTLED("throttled");
+ THROTTLED("throttled"),
+ /** Something was masked and the configuration says not to send a log in which something was. */
+ BLOCKED("blocked");
private final String value;
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/explanation.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/explanation.jelly
index 8ad2561e..53e93395 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/explanation.jelly
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/explanation.jelly
@@ -51,15 +51,19 @@
-
+
${it.providerLabel}
·
${labels.linesAnalysed(it.inputLogLineCount)}
-
+
·
+ ${labels.secretsMasked(it.redactedCount)}
+
+
+ ·
${labels.generatedOn(it.formattedTimestamp)}
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels.properties
index 1243da45..907726d2 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels.properties
@@ -8,3 +8,5 @@ whereItFailed=Where it failed
linesAnalysed={0} log lines analysed
generatedOn=Generated on {0}
reusedFrom=Reused from build #{0}, which failed the same way
+secretMasked=1 secret masked
+secretsMasked={0} secrets masked
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_de.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_de.properties
index 69e70edd..a8476552 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_de.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_de.properties
@@ -8,3 +8,5 @@ whereItFailed=Fehlerstelle
linesAnalysed={0} Protokollzeilen analysiert
generatedOn=Erstellt am {0}
reusedFrom=Übernommen von Build #{0}, der auf dieselbe Weise fehlschlug
+secretMasked=1 Geheimnis maskiert
+secretsMasked={0} Geheimnisse maskiert
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_es.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_es.properties
index cdc515ce..91a2f842 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_es.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_es.properties
@@ -8,3 +8,5 @@ whereItFailed=Dónde falló
linesAnalysed={0} líneas de registro analizadas
generatedOn=Generado el {0}
reusedFrom=Reutilizada de la compilación #{0}, que falló de la misma forma
+secretMasked=1 secreto ocultado
+secretsMasked={0} secretos ocultados
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_fr.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_fr.properties
index b4c9b636..65c33084 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_fr.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_fr.properties
@@ -8,3 +8,5 @@ whereItFailed=Où l'échec s'est produit
linesAnalysed={0} lignes de journal analysées
generatedOn=Généré le {0}
reusedFrom=Reprise du build #{0}, qui a échoué de la même façon
+secretMasked=1 secret masqué
+secretsMasked={0} secrets masqués
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_ja.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_ja.properties
index e0a300cc..baa08468 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_ja.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_ja.properties
@@ -8,3 +8,5 @@ whereItFailed=失敗箇所
linesAnalysed=ログ {0} 行を分析
generatedOn={0} に生成
reusedFrom=同じ原因で失敗したビルド #{0} の説明を再利用
+secretMasked=シークレット 1 件をマスク
+secretsMasked=シークレット {0} 件をマスク
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_CN.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_CN.properties
index 8be41cd4..73e4387c 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_CN.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_CN.properties
@@ -8,3 +8,5 @@ whereItFailed=失败位置
linesAnalysed=已分析 {0} 行日志
generatedOn=生成于 {0}
reusedFrom=复用自构建 #{0}(失败原因相同)
+secretMasked=已遮盖 1 处敏感信息
+secretsMasked=已遮盖 {0} 处敏感信息
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_TW.properties b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_TW.properties
index cb546674..945b3dc1 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_TW.properties
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplanationLabels_zh_TW.properties
@@ -8,3 +8,5 @@ whereItFailed=失敗位置
linesAnalysed=已分析 {0} 行日誌
generatedOn=產生於 {0}
reusedFrom=沿用自建置 #{0}(失敗原因相同)
+secretMasked=已遮蓋 1 處敏感資訊
+secretsMasked=已遮蓋 {0} 處敏感資訊
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/GlobalConfigurationImpl/config.jelly b/src/main/resources/io/jenkins/plugins/explain_error/GlobalConfigurationImpl/config.jelly
index 996d28f6..6c9d8875 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/GlobalConfigurationImpl/config.jelly
+++ b/src/main/resources/io/jenkins/plugins/explain_error/GlobalConfigurationImpl/config.jelly
@@ -24,6 +24,18 @@
description="Optional regular expression, matched without regard to case. Only the log lines it matches are sent, searched for in the whole log of each failing step; when it matches nothing, the last lines are sent as they are. Applies to automatic explanations, the Explain Error button and explainError() steps that set no logPattern of their own.">
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/lib/explain-error/card.jelly b/src/main/resources/lib/explain-error/card.jelly
index 33f249f9..60559734 100644
--- a/src/main/resources/lib/explain-error/card.jelly
+++ b/src/main/resources/lib/explain-error/card.jelly
@@ -45,6 +45,8 @@
data-has-explanation="${hasExplanation}" data-plugin-enabled="${attrs.enabled}"
data-label-explain="${%Explain Error}" data-label-show="${%Show explanation}"
data-label-pending="${%Explaining…}"
+ data-label-preview="${%Preview what is sent}"
+ data-tooltip-preview="${%previewTooltip}"
data-failure-headline="${%The explanation could not be generated.}">
diff --git a/src/main/resources/lib/explain-error/card.properties b/src/main/resources/lib/explain-error/card.properties
index 2d8f588e..01ab81bf 100644
--- a/src/main/resources/lib/explain-error/card.properties
+++ b/src/main/resources/lib/explain-error/card.properties
@@ -1 +1,2 @@
reexplain=Re-explain with {0}
+previewTooltip=The log lines a new explanation sends to the AI provider, with secrets masked. Nothing is sent.
diff --git a/src/main/webapp/js/explain-error-footer.js b/src/main/webapp/js/explain-error-footer.js
index dcd735d0..d86192f5 100644
--- a/src/main/webapp/js/explain-error-footer.js
+++ b/src/main/webapp/js/explain-error-footer.js
@@ -111,9 +111,14 @@ function addExplainErrorButton(explanation) {
const explainBtn = createButton(container.dataset.labelExplain, 'jenkins-button explain-error-btn',
openExplanation, providerName);
+ const previewLink = enabled ? createPreviewLink(container) : null;
+
// If we found the button container, add our button there
if (buttonContainer) {
buttonContainer.insertBefore(explainBtn, buttonContainer.firstChild);
+ if (previewLink) {
+ explainBtn.after(previewLink);
+ }
Behaviour.applySubtree(buttonContainer, true);
} else {
// Fallback: create a simple container above console output
@@ -121,6 +126,9 @@ function addExplainErrorButton(explanation) {
container.className = 'explain-error-container';
container.style.marginBottom = '10px';
container.appendChild(explainBtn);
+ if (previewLink) {
+ container.appendChild(previewLink);
+ }
consoleOutput.parentNode.insertBefore(container, consoleOutput);
Behaviour.applySubtree(container, true);
}
@@ -173,6 +181,28 @@ function openExplanation() {
sendExplainRequest(false);
}
+// Opens, in a new tab, the text a new explanation would send to the provider, after masking. The preview
+// takes a POST with the crumb, so it is a form.
+function createPreviewLink(container) {
+ const form = document.createElement('form');
+ const rootURL = document.head.getAttribute("data-rooturl");
+ form.method = 'post';
+ form.action = rootURL + '/' + container.dataset.runUrl + 'console-explain-error/preview';
+ form.target = '_blank';
+ form.rel = 'noopener';
+ // no-json: Jenkins adds a json field to a form it sees; the preview takes none
+ form.className = 'explain-error-preview no-json';
+ form.style.display = 'contents';
+ crumb.appendToForm(form);
+ const button = document.createElement('button');
+ button.type = 'submit';
+ button.className = 'jenkins-button jenkins-button--tertiary';
+ button.textContent = container.dataset.labelPreview;
+ button.setAttribute('tooltip', container.dataset.tooltipPreview);
+ form.appendChild(button);
+ return form;
+}
+
function createButton(text, className, onClick, providerName) {
const btn = document.createElement('button');
btn.textContent = text;
diff --git a/src/test/java/io/jenkins/plugins/explain_error/ErrorRunListenerTest.java b/src/test/java/io/jenkins/plugins/explain_error/ErrorRunListenerTest.java
index 1cdb6e5d..79425a22 100644
--- a/src/test/java/io/jenkins/plugins/explain_error/ErrorRunListenerTest.java
+++ b/src/test/java/io/jenkins/plugins/explain_error/ErrorRunListenerTest.java
@@ -8,6 +8,7 @@
import hudson.model.FreeStyleBuild;
import hudson.model.FreeStyleProject;
import hudson.model.Result;
+import hudson.model.Run;
import io.jenkins.plugins.explain_error.provider.FakeAIProvider;
import io.jenkins.plugins.explain_error.provider.OpenAIProvider;
import org.junit.jupiter.api.Test;
@@ -127,6 +128,7 @@ void exceptionInListenerDoesNotBreakBuild(JenkinsRule jenkins) throws Exception
assertNotNull(build);
assertEquals(Result.FAILURE, build.getResult());
+ awaitAutoExplainFinished(build);
// No action because the provider threw; the listener catches internally
assertNull(build.getAction(ErrorExplanationAction.class),
"No action should be present when provider throws");
@@ -160,6 +162,7 @@ private static ErrorExplanationAction awaitExplanation(FreeStyleBuild build) thr
for (int i = 0; i < 100; i++) {
ErrorExplanationAction action = build.getAction(ErrorExplanationAction.class);
if (action != null) {
+ awaitAutoExplainFinished(build);
return action;
}
Thread.sleep(100);
@@ -167,6 +170,17 @@ private static ErrorExplanationAction awaitExplanation(FreeStyleBuild build) thr
return build.getAction(ErrorExplanationAction.class);
}
+ /**
+ * Waits until the automatic explanation of a build is done with it, saving included, so that the test does
+ * not end, and its Jenkins home get deleted, while the explanation still writes to the build.
+ */
+ static void awaitAutoExplainFinished(Run, ?> build) throws InterruptedException {
+ for (int i = 0; i < 300 && PendingExplanations.contains(build); i++) {
+ Thread.sleep(100);
+ }
+ assertFalse(PendingExplanations.contains(build), "the automatic explanation finishes");
+ }
+
/**
* Asserts that no explanation action ever appears. Polls for a short window
* so a regression that wrongly dispatched the async explanation would be
diff --git a/src/test/java/io/jenkins/plugins/explain_error/LogRedactorTest.java b/src/test/java/io/jenkins/plugins/explain_error/LogRedactorTest.java
new file mode 100644
index 00000000..d70b5875
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/explain_error/LogRedactorTest.java
@@ -0,0 +1,353 @@
+package io.jenkins.plugins.explain_error;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * What the built-in rules mask, and what they leave alone. The secrets are put together at run time, so that
+ * no secret scanner takes this file for a leak.
+ */
+class LogRedactorTest {
+
+ private static final String MASK = LogRedactor.MASK;
+
+ static final String AWS_KEY_ID = "AKIA" + "IOSFODNN7EXAMPLE";
+ static final String GITHUB_TOKEN = "ghp" + "_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8";
+ static final String GITLAB_TOKEN = "glpat" + "-" + "x1y2z3A4B5C6D7E8F9G0";
+ static final String SLACK_TOKEN = "xox" + "b-1234567890-abcdefghij";
+ static final String GOOGLE_KEY = "AIza" + "SyA1234567890abcdefghijklmnopqrstuv";
+ static final String JWT = "eyJ" + "hbGciOiJIUzI1NiJ9.eyJ" + "zdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N";
+ static final String OPENAI_KEY = "sk-" + "proj-" + "Ab3dEf6hIj9kLm2nOp5qRs8tUv1w";
+ static final String ANTHROPIC_KEY = "sk-" + "ant-api03-" + "Ab3dEf6hIj9kLm2nOp5qRs8tUv1w";
+
+ private static final String KEY_BODY = "MIIEowIBAAKCAQEA7yn3bRHQ5FHMQ\nTW9yZSBiYXNlNjQgdGV4dCBoZXJl\nAB12";
+
+ private static String redact(String text) {
+ return LogRedactor.builtIn().redact(text);
+ }
+
+ private static String header(String line) {
+ return "-----" + line + "-----";
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {AWS_KEY_ID, GITHUB_TOKEN, GITLAB_TOKEN, SLACK_TOKEN, GOOGLE_KEY, JWT, OPENAI_KEY,
+ ANTHROPIC_KEY})
+ void tokensOfAKnownShapeAreMasked(String token) {
+ String masked = redact("+ echo " + token + " done");
+
+ assertEquals("+ echo " + MASK + " done", masked);
+ }
+
+ @Test
+ void aValueGivenToAKeyNamedLikeASecretKeepsItsKey() {
+ String log = String.join("\n",
+ "DB_PASSWORD=hunter2hunter2",
+ "export API_KEY='s3cr3t-value'",
+ "{\"client_secret\": \"abc123def456\"}",
+ "{\\\"password\\\":\\\"esc4ped-json\\\"}",
+ "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ "SECRET_KEY_BASE=0123456789abcdef",
+ " password: \"hunter\"",
+ " password: hunter2",
+ "spring.datasource.password=s3cret!",
+ "//registry.npmjs.org/:_authToken=npm-secret-1",
+ "DefaultEndpointsProtocol=https;AccountName=acme;AccountKey=a1b2c3d4==;EndpointSuffix=core.windows.net",
+ "GET /callback?access_token=abc123xyz&state=ok",
+ "mvn deploy -Dgpg.passphrase=pass-phrase-1 -B",
+ "mvn deploy --password hunter2 -B",
+ "mysql --password 'p@ss!w0rd' -h db",
+ "npm publish --token=npmsecretvalue",
+ "curl -u admin:S3cr3t https://example.com",
+ "Internal KB: token=kb-secret-123",
+ "PGPASSWORD=hunter2hunter2 psql -h db",
+ "POSTGRES_PASSWORD: example",
+ "password: correcthorsebatterystaple",
+ " spring.datasource.password: changeme",
+ "DB_PASSWORD=ab&cdefgh",
+ "mysql --password \"it's s3cret\" -h db",
+ "curl -uadmin:S3cr3t https://example.com",
+ "curl -sSf https://example.com/upload \\\n -u admin:S3cr3t",
+ "[INFO] password: changeme",
+ "#8 0.312 password: hunter",
+ "curl -X POST -H \"Content-Type: application/json; charset=utf-8\" -u admin:S3cr3tPass https://x",
+ "curl -d 'a|b' -u admin:S3cr3t https://x");
+
+ String masked = redact(log);
+
+ assertEquals(String.join("\n",
+ "DB_PASSWORD=" + MASK,
+ "export API_KEY='" + MASK + "'",
+ "{\"client_secret\": \"" + MASK + "\"}",
+ "{\\\"password\\\":\\\"" + MASK + "\\\"}",
+ "aws_secret_access_key = " + MASK,
+ "SECRET_KEY_BASE=" + MASK,
+ " password: \"" + MASK + "\"",
+ " password: " + MASK,
+ "spring.datasource.password=" + MASK,
+ "//registry.npmjs.org/:_authToken=" + MASK,
+ "DefaultEndpointsProtocol=https;AccountName=acme;AccountKey=" + MASK
+ + ";EndpointSuffix=core.windows.net",
+ "GET /callback?access_token=" + MASK + "&state=ok",
+ "mvn deploy -Dgpg.passphrase=" + MASK + " -B",
+ "mvn deploy --password " + MASK + " -B",
+ "mysql --password '" + MASK + "' -h db",
+ "npm publish --token=" + MASK,
+ "curl -u admin:" + MASK + " https://example.com",
+ "Internal KB: token=" + MASK,
+ "PGPASSWORD=" + MASK + " psql -h db",
+ "POSTGRES_PASSWORD: " + MASK,
+ "password: " + MASK,
+ " spring.datasource.password: " + MASK,
+ "DB_PASSWORD=" + MASK,
+ "mysql --password \"" + MASK + "\" -h db",
+ "curl -uadmin:" + MASK + " https://example.com",
+ "curl -sSf https://example.com/upload \\\n -u admin:" + MASK,
+ "[INFO] password: " + MASK,
+ "#8 0.312 password: " + MASK,
+ "curl -X POST -H \"Content-Type: application/json; charset=utf-8\" -u admin:" + MASK + " https://x",
+ "curl -d 'a|b' -u admin:" + MASK + " https://x"), masked);
+ }
+
+ @Test
+ void anXmlElementNamedLikeASecretIsMasked() {
+ String masked = redact("
nexuscipom-secret-1"
+ + "\n
x\n
${env.NEXUS_PASSWORD}\n"
+ + "
pom-secret-2");
+
+ assertEquals("
nexusci" + MASK + ""
+ + "\n
" + MASK + "\n"
+ + "
${env.NEXUS_PASSWORD}\n
" + MASK + "",
+ masked);
+ }
+
+ @Test
+ void theCredentialsOfAUrlOrAnAuthorizationHeaderAreMasked() {
+ String masked = redact(String.join("\n",
+ "Cloning https://deploy:Sup3rS3cret@git.example.com/repo.git",
+ "curl -H 'Authorization: Bearer abc.def.1234567890'",
+ "Authorization: Basic dXNlcjpwYXNzd29yZA1="));
+
+ assertEquals(String.join("\n",
+ "Cloning https://deploy:" + MASK + "@git.example.com/repo.git",
+ "curl -H 'Authorization: Bearer " + MASK + "'",
+ "Authorization: Basic " + MASK), masked);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"OPENSSH PRIVATE KEY", "RSA PRIVATE KEY", "PRIVATE KEY", "ENCRYPTED PRIVATE KEY",
+ "PGP PRIVATE KEY BLOCK"})
+ void aPrivateKeyIsMaskedWhole(String kind) {
+ String log = "before\n" + header("BEGIN " + kind) + "\nVersion: 1\n\n" + KEY_BODY + "\n"
+ + header("END " + kind) + "\nafter";
+
+ assertEquals("before\n" + MASK + "\nafter", redact(log));
+ }
+
+ @Test
+ void aPrivateKeyInAJsonStringIsMaskedWhole() {
+ String json = "\"private_key\": \"" + header("BEGIN PRIVATE KEY") + "\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC"
+ + "\\nBKcwggSjAgEAAoIBAQC7\\n" + header("END PRIVATE KEY") + "\\n\",";
+
+ assertEquals("\"private_key\": \"" + MASK + "\\n\",", redact(json));
+ }
+
+ @Test
+ void aPrivateKeyOnLinesWithAPrefixIsMaskedFromItsFirstLineToItsLast() {
+ String log = "#8 0.312 " + header("BEGIN RSA PRIVATE KEY") + "\n#8 0.312 MIIEowIBAAKCAQEA7yn3bRHQ5FHMQ\n"
+ + "2024-05-01T10:00:00Z TW9yZSBiYXNlNjQgdGV4dCBoZXJl\n#8 0.313 " + header("END RSA PRIVATE KEY")
+ + "\n#8 0.314 DONE";
+
+ assertEquals("#8 0.312 " + MASK + "\n#8 0.314 DONE", redact(log));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"\\n", "\\r\\n"})
+ void theEndOfAPrivateKeyInAJsonStringIsMaskedWithItsBody(String lineBreak) {
+ String json = "\"key\": \"MIIEvQIBADANBgkqhkiG9w0BAQEFAASC" + lineBreak + "BKcwggSjAgEAAoIBAQC7" + lineBreak
+ + header("END PRIVATE KEY") + lineBreak + "\"";
+
+ assertEquals("\"key\": \"" + MASK + lineBreak + "\"", redact(json));
+ }
+
+ @Test
+ void aKeyHeaderOnItsOwnTakesNoLogLinesWithIt() {
+ String key = header("BEGIN RSA PRIVATE KEY") + "\n" + KEY_BODY + "\n" + header("END RSA PRIVATE KEY");
+ String scan = "found '" + header("BEGIN RSA PRIVATE KEY") + "' in deploy/id_rsa\nERROR: deploy failed\n" + key;
+ String otherKind = header("BEGIN OPENSSH PRIVATE KEY") + "\nERROR: deploy failed\n"
+ + header("END PGP PRIVATE KEY BLOCK");
+
+ assertEquals("found '" + MASK + "' in deploy/id_rsa\nERROR: deploy failed\n" + MASK, redact(scan));
+ assertEquals(MASK + "\nERROR: deploy failed\n" + MASK, redact(otherKind));
+ }
+
+ @Test
+ void aPrivateKeyCutShortIsMaskedUpToWhereItWasCut() {
+ String start = "key:\n" + header("BEGIN RSA PRIVATE KEY") + "\n" + KEY_BODY.substring(0, 58)
+ + "\n0123456789abcdef0123456789abcdef refs/heads/main";
+ String end = "ERROR: before the key\n" + KEY_BODY + "\n" + header("END RSA PRIVATE KEY") + "\nafter";
+
+ assertEquals("key:\n" + MASK + "\n0123456789abcdef0123456789abcdef refs/heads/main", redact(start));
+ assertEquals("ERROR: before the key\n" + MASK + "\nafter", redact(end));
+ }
+
+ @Test
+ void aSecretInATokenUrlIsCountedOnce() {
+ LogRedactor redactor = LogRedactor.builtIn();
+
+ String masked = redactor.redact("git clone https://x-access-token:" + GITHUB_TOKEN + "@github.com/o/r");
+
+ assertEquals("git clone https://x-access-token:" + MASK + "@github.com/o/r", masked);
+ assertEquals(1, redactor.count());
+ }
+
+ @Test
+ void theCountAddsUpOverEveryTextGiven() {
+ LogRedactor redactor = LogRedactor.builtIn();
+
+ redactor.redact("PASSWORD=one-secret and " + AWS_KEY_ID);
+ redactor.redact("token: " + GITLAB_TOKEN);
+
+ assertEquals(3, redactor.count());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "PWD=/home/jenkins/workspace/app",
+ "withCredentials: credentialsId 'deploy-key' was not found",
+ "Bearer authentication failed",
+ "password=${DB_PASSWORD}",
+ "Password: ****",
+ "PASSWORD=[REDACTED]",
+ "Password:\nERROR: build failed",
+ "ERROR: Invalid token: expired",
+ "token_type: bearer",
+ "secretName: registry-credentials",
+ "DB_PASSWORD_FILE=/run/secrets/db_password",
+ "sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+ "[ERROR] Failed to execute goal on project app: Could not resolve dependencies",
+ "[ERROR] Failed to execute goal on project token-service: Compilation failure",
+ "[ERROR] /src/main/java/com/acme/TokenService.java:[142,17] cannot find symbol",
+ "java.lang.NullPointerException at com.example.TokenParser.parse(TokenParser.java:42)",
+ "\tat org.acme.token.DefaultTokenServices.createAccessToken(DefaultTokenServices.java:123)",
+ "src/auth/token.ts:120:5 - error TS2322: Type 'string' is not assignable to type 'number'.",
+ "/app/src/password_reset.py:104: error: Incompatible types in assignment",
+ "Could not find artifact io.jsonwebtoken:jjwt-impl:jar:0.12.0 in central",
+ "Downloading software.amazon.awssdk:secretsmanager:jar:2.20.100",
+ "Collecting tokenizers==0.15.0",
+ "ERROR: No matching distribution found for tiktoken==0.5.1",
+ "image: ghcr.io/acme/vault-secrets-operator:0.4.0",
+ "Downloaded from central: https://repo.maven.apache.org/maven2/org/x/1.0/x-1.0.jar",
+ "ssh://git@github.com:22/org/repo.git",
+ "Running on agent-token-builder in /home/jenkins/agent",
+ "$ docker run -t -d -u 1000:1000 -w /home/jenkins/workspace/app maven:3 cat",
+ "docker exec -u root:root build-1 ls",
+ "sudo -u jenkins:jenkins ls",
+ "curl -s https://example.com/install.sh | docker run -u 1000:1000 -i alpine sh",
+ "curl -s -H 'Accept: text/plain' https://example.com/x; docker exec -u 0:0 build-1 ls",
+ "Failed to read secret: permission denied",
+ "password=null",
+ "token=undefined",
+ "\"max_tokens\": 4096",
+ "model.generate(max_new_tokens=2048)",
+ "\"prompt_tokens\": 1520, \"completion_tokens\": 3001, \"total_tokens\": 4521",
+ "remaining tokens: 12000",
+ "MAX_TOKENS=4096",
+ "image: ghcr.io/external-secrets/external-secrets:v0.9.11"})
+ void whatIsNotASecretIsLeftAsItIs(String line) {
+ LogRedactor redactor = LogRedactor.builtIn();
+
+ assertEquals(line, redactor.redact(line));
+ assertEquals(0, redactor.count());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"DB_PASSWORD", "clientSecret", "apiKey", "API_KEY", "AccountKey", "SharedAccessKey",
+ "AWS_SECRET_ACCESS_KEY", "SECRET_KEY_BASE", "_authToken", "gpg.passphrase", "DBPassword", "ns:password",
+ "PGPASSWORD", "KEYSTOREPASSWORD", "NPMTOKEN"})
+ void keysNamedLikeASecret(String key) {
+ assertTrue(LogRedactor.isSecretKey(key), key);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"TokenService.java", "password_reset", "jsonwebtoken", "DB_PASSWORD_FILE",
+ "token_type", "secretName", "secretsmanager", "tokenizers", "credentialsId", "PWD", "key", "monkey",
+ "max_tokens", "MAX_TOKENS", "external-secrets", "tiktoken"})
+ void keysThatAreNot(String key) {
+ assertFalse(LogRedactor.isSecretKey(key), key);
+ }
+
+ @Test
+ void aPatternOfTheAdministratorMasksItsSecretGroupOrElseTheWholeMatch() {
+ LogRedactor redactor = new LogRedactor(false, List.of(
+ Pattern.compile("(?i)license=(?
\\S+)"),
+ Pattern.compile("\\b[a-z0-9]+\\.corp\\.example\\.com\\b"),
+ // Looks like it has a group named secret, but has none
+ Pattern.compile("\\(?x")));
+
+ String masked = redactor.redact("license=ABCD-1234 from build01.corp.example.com and x");
+
+ assertEquals("license=" + MASK + " from " + MASK + " and " + MASK, masked);
+ assertEquals(3, redactor.count());
+ }
+
+ @Test
+ void patternLinesLeaveOutBlankLinesAndComments() {
+ assertEquals(List.of("one", "two"), LogRedactor.patternLines("# internal hosts\n one \n\n\r\ntwo\n"));
+ assertTrue(LogRedactor.patternLines(null).isEmpty());
+ }
+
+ @Test
+ void withoutRulesNothingChanges() {
+ LogRedactor redactor = new LogRedactor(false, List.of());
+
+ assertEquals("PASSWORD=hunter2hunter2", redactor.redact("PASSWORD=hunter2hunter2"));
+ assertFalse(redactor.isActive());
+ assertNull(redactor.redact(null));
+ }
+
+ /**
+ * Whoever writes the log chooses what the rules run on. A thousand lines of 2,000 characters, as many as the
+ * Explain Error button reads, built to make a rule go back over what it read, take a few seconds at most; a
+ * rule whose time grows with the square of the length takes many minutes on them.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "-----BEGIN RSA PRIVATE KEY-----",
+ "-----END RSA PRIVATE KEY-----",
+ "MIIEowIBAAKCAQEA7yn3bRHQ5FHMQ\n",
+ "token.",
+ "a-",
+ "password",
+ "sk-proj-",
+ "Bearer ",
+ "Authorization: Bearer ",
+ "https://a:",
+ "",
+ "--password ",
+ "-u a:",
+ "KB:",
+ "x=token=",
+ "curl ",
+ "curl -u a:",
+ "curl \"a;\" ",
+ "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA7yn3bRHQ5FHMQ\n"})
+ void rulesTakeTimeInProportionToTheLog(String piece) {
+ String line = piece.repeat(2000 / piece.length());
+ String log = String.join("\n", Collections.nCopies(1000, line));
+
+ assertTimeoutPreemptively(Duration.ofSeconds(60), () -> redact(log),
+ "the rules must not go back over the log: " + piece);
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/explain_error/RedactionTest.java b/src/test/java/io/jenkins/plugins/explain_error/RedactionTest.java
new file mode 100644
index 00000000..c2edeb89
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/explain_error/RedactionTest.java
@@ -0,0 +1,334 @@
+package io.jenkins.plugins.explain_error;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import hudson.model.FreeStyleBuild;
+import hudson.model.FreeStyleProject;
+import hudson.model.Item;
+import hudson.model.Result;
+import hudson.util.FormValidation;
+import io.jenkins.plugins.explain_error.provider.FakeAIProvider;
+import java.net.URL;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import jenkins.model.Jenkins;
+import net.sf.json.JSONObject;
+import org.htmlunit.HttpMethod;
+import org.htmlunit.Page;
+import org.htmlunit.WebRequest;
+import org.htmlunit.WebResponse;
+import org.htmlunit.html.HtmlElement;
+import org.htmlunit.html.HtmlPage;
+import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
+import org.jenkinsci.plugins.workflow.job.WorkflowJob;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.MockAuthorizationStrategy;
+import org.jvnet.hudson.test.TestBuilder;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+/** Secrets are masked in everything sent to the AI provider, wherever the explanation is asked for. */
+@WithJenkins
+class RedactionTest {
+
+ private static final String AWS_KEY_ID = LogRedactorTest.AWS_KEY_ID;
+ private static final String GITHUB_TOKEN = LogRedactorTest.GITHUB_TOKEN;
+ private static final String LEAKY_STEP = "sh '''set +x\necho \"DB_PASSWORD=hunter2hunter2\"\necho \"key "
+ + AWS_KEY_ID + "\"\necho \"BUILD FAILED: cannot connect to db\"\nexit 1'''";
+
+ private final FakeAIProvider provider = new FakeAIProvider();
+ private JenkinsRule jenkins;
+
+ @BeforeEach
+ void setUp(JenkinsRule jenkins) {
+ this.jenkins = jenkins;
+ GlobalConfigurationImpl.get().setAiProvider(provider);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void theStepSendsTheLogWithItsSecretsMasked() throws Exception {
+ WorkflowRun run = explain("step", "explainError()", LEAKY_STEP);
+
+ String sent = provider.getLastErrorLogs();
+ assertFalse(sent.contains("hunter2hunter2") || sent.contains(AWS_KEY_ID), sent);
+ assertTrue(sent.contains("DB_PASSWORD=[REDACTED]") && sent.contains("BUILD FAILED"), sent);
+ jenkins.assertLogContains("Masked 2 secrets in what is sent to the AI provider.", run);
+ ErrorExplanationAction action = run.getAction(ErrorExplanationAction.class);
+ assertEquals(2, action.getRedactedCount());
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void customContextIsMaskedToo() throws Exception {
+ explain("context", "explainError(customContext: 'Deploy with api_key=ctx-secret-123')", LEAKY_STEP);
+
+ String context = provider.getLastCustomContext();
+ assertTrue(context.contains("Deploy with api_key=[REDACTED]"), context);
+ assertFalse(context.contains("ctx-secret-123"), context);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void workspaceFilesSentAsContextAreMasked() throws Exception {
+ explain("workspace", "explainError(includeWorkspaceContext: true, workspaceContextPaths: 'pom.xml')",
+ "writeFile file: 'pom.xml', text: 'pom-secret-1https://ci:"
+ + "pom-secret-2@repo.example.com'\n" + LEAKY_STEP);
+
+ String context = provider.getLastCustomContext();
+ assertFalse(context.contains("pom-secret-1") || context.contains("pom-secret-2"), context);
+ assertTrue(context.contains("[REDACTED]"), context);
+ assertTrue(context.contains("https://ci:[REDACTED]@repo.example.com"), context);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void withMaskingOffTheLogIsSentAsItIs() throws Exception {
+ GlobalConfigurationImpl.get().setRedactSecrets(false);
+
+ WorkflowRun run = explain("off", "explainError()", LEAKY_STEP);
+
+ assertTrue(provider.getLastErrorLogs().contains(AWS_KEY_ID), provider.getLastErrorLogs());
+ jenkins.assertLogNotContains("Masked", run);
+ assertEquals(0, run.getAction(ErrorExplanationAction.class).getRedactedCount());
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void thePatternsOfTheAdministratorApplyEvenWithTheBuiltInRulesOff() throws Exception {
+ GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
+ config.setRedactSecrets(false);
+ config.setRedactionPatterns("# the database\n(?i)cannot connect to (?db)\n");
+
+ explain("own", "explainError()", LEAKY_STEP);
+
+ String sent = provider.getLastErrorLogs();
+ assertTrue(sent.contains("cannot connect to [REDACTED]"), sent);
+ assertTrue(sent.contains(AWS_KEY_ID), "the built-in rules are off: " + sent);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void nothingIsSentWhenSomethingWasMaskedAndTheConfigurationSaysSo() throws Exception {
+ GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
+ config.setBlockWhenRedacted(true);
+ config.setEnableQuota(true);
+ config.setMaxProviderCallsPerWindow(1);
+ RecordingUsageRecorder recorder = new RecordingUsageRecorder();
+ UsageRecorders.setRecorderSupplier(() -> List.of(recorder));
+ try {
+ WorkflowRun run = explain("block", "explainError(autoFix: true, autoFixCredentialsId: 'scm-token')",
+ LEAKY_STEP);
+
+ jenkins.assertLogContains("RETURNED=false", run);
+ jenkins.assertLogContains(ErrorExplainer.BLOCKED, run);
+ jenkins.assertLogContains("[AutoFix] Skipped: no error logs available", run);
+ assertEquals(0, provider.getCallCount());
+ assertNull(run.getAction(ErrorExplanationAction.class));
+ assertEquals(List.of(UsageEvent.Result.BLOCKED), recorder.results());
+ assertTrue(config.tryAcquireQuota(), "a request that sent nothing used no quota");
+ } finally {
+ UsageRecorders.resetRecorderSupplier();
+ }
+ }
+
+ @Test
+ void anAutomaticExplanationIsMaskedToo() throws Exception {
+ GlobalConfigurationImpl.get().setEnableAutoExplainOnFailure(true);
+
+ FreeStyleBuild build = leakyFreestyleBuild();
+
+ ErrorExplanationAction action = null;
+ for (int i = 0; i < 300 && action == null; i++) {
+ action = build.getAction(ErrorExplanationAction.class);
+ if (action == null) {
+ Thread.sleep(100);
+ }
+ }
+ assertNotNull(action, "the build is explained automatically");
+ ErrorRunListenerTest.awaitAutoExplainFinished(build);
+ String sent = provider.getLastErrorLogs();
+ assertFalse(sent.contains(GITHUB_TOKEN), sent);
+ assertTrue(sent.contains("https://x-access-token:[REDACTED]@github.com/o/r"), sent);
+ assertEquals(1, action.getRedactedCount());
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void aLogWithNothingToMaskIsSentEvenWhenBlocking() throws Exception {
+ GlobalConfigurationImpl.get().setBlockWhenRedacted(true);
+
+ WorkflowRun run = explain("clean", "explainError()", "sh 'echo plain failure; exit 1'");
+
+ jenkins.assertLogContains("RETURNED=true", run);
+ assertEquals(1, provider.getCallCount());
+ }
+
+ @Test
+ void theConsoleButtonSendsTheLogMasked() throws Exception {
+ jenkins.jenkins.setCrumbIssuer(null);
+ FreeStyleBuild build = leakyFreestyleBuild();
+
+ JSONObject response = postExplain(build);
+
+ assertEquals("success", response.getString("status"), response.toString());
+ String sent = provider.getLastErrorLogs();
+ assertFalse(sent.contains(GITHUB_TOKEN), sent);
+ assertTrue(sent.contains("https://x-access-token:[REDACTED]@github.com/o/r"), sent);
+ assertEquals(1, build.getAction(ErrorExplanationAction.class).getRedactedCount());
+ }
+
+ @Test
+ void theConsoleButtonSaysWhyNothingWasSent() throws Exception {
+ jenkins.jenkins.setCrumbIssuer(null);
+ GlobalConfigurationImpl.get().setBlockWhenRedacted(true);
+ FreeStyleBuild build = leakyFreestyleBuild();
+
+ JSONObject response = postExplain(build);
+
+ assertEquals("warning", response.getString("status"), response.toString());
+ assertTrue(response.getString("message").contains(ErrorExplainer.BLOCKED), response.toString());
+ assertEquals(0, provider.getCallCount());
+ }
+
+ @Test
+ void thePreviewShowsWhatTheButtonWouldSendAndSendsNothing() throws Exception {
+ jenkins.jenkins.setSecurityRealm(jenkins.createDummySecurityRealm());
+ jenkins.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()
+ .grant(Jenkins.READ, Item.READ).everywhere().to("reader")
+ .grant(Jenkins.READ).everywhere().to("outsider"));
+ FreeStyleBuild build = leakyFreestyleBuild();
+
+ GlobalConfigurationImpl.get().setCustomContext("Internal KB: token=kb-secret-123");
+
+ try (JenkinsRule.WebClient client = jenkins.createWebClient().login("reader")) {
+ Page page = client.getPage(client.addCrumb(previewRequest(build)));
+ assertEquals("text/plain", page.getWebResponse().getContentType());
+ String preview = page.getWebResponse().getContentAsString();
+ assertTrue(preview.contains("Masked 2 secrets in what is sent"), preview);
+ assertTrue(preview.contains("1 of them in the custom context, which is not shown here."), preview);
+ assertFalse(preview.contains("kb-secret-123") || preview.contains("Internal KB"), preview);
+ assertTrue(preview.contains("https://x-access-token:[REDACTED]@github.com/o/r"), preview);
+ assertFalse(preview.contains(GITHUB_TOKEN), preview);
+ }
+ try (JenkinsRule.WebClient client = jenkins.createWebClient().login("outsider")) {
+ client.setThrowExceptionOnFailingStatusCode(false);
+ int status = client.getPage(client.addCrumb(previewRequest(build))).getWebResponse().getStatusCode();
+ assertTrue(status == 403 || status == 404, "no Item/Read, no preview: " + status);
+ }
+ try (JenkinsRule.WebClient client = jenkins.createWebClient().login("reader")) {
+ client.setThrowExceptionOnFailingStatusCode(false);
+ int status = client.goTo(build.getUrl() + "console-explain-error/preview", null)
+ .getWebResponse().getStatusCode();
+ assertEquals(405, status, "a GET, as from a link on another site, gets nothing");
+ }
+ assertEquals(0, provider.getCallCount());
+ }
+
+ @Test
+ void thePreviewButtonOnTheConsoleOpensThePreview() throws Exception {
+ FreeStyleBuild build = leakyFreestyleBuild();
+
+ try (JenkinsRule.WebClient client = jenkins.createWebClient()) {
+ HtmlPage console = client.getPage(build, "console");
+ client.waitForBackgroundJavaScript(10_000);
+ HtmlElement button = console.querySelector("form.explain-error-preview button");
+ assertNotNull(button, "the console page has the preview button");
+
+ button.click();
+ client.waitForBackgroundJavaScript(5_000);
+
+ String preview = client.getTopLevelWindows().stream()
+ .map(window -> window.getEnclosedPage().getWebResponse())
+ .filter(response -> response.getWebRequest().getUrl().getPath().endsWith("/preview"))
+ .map(WebResponse::getContentAsString)
+ .findFirst().orElseThrow(() -> new AssertionError("the preview opens in a window"));
+ assertTrue(preview.contains("https://x-access-token:[REDACTED]@github.com/o/r"), preview);
+ assertFalse(preview.contains(GITHUB_TOKEN), preview);
+ }
+ assertEquals(0, provider.getCallCount());
+ }
+
+ @Test
+ void anInvalidPatternIsReportedOnTheForm() {
+ GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
+
+ assertEquals(FormValidation.Kind.OK, config.doCheckRedactionPatterns("# hosts\n\\bbuild\\d+\\b").kind);
+ FormValidation invalid = config.doCheckRedactionPatterns("ok\n([");
+ assertEquals(FormValidation.Kind.ERROR, invalid.kind);
+ assertTrue(invalid.getMessage().contains("Pattern 2"), invalid.getMessage());
+ }
+
+ @Test
+ void theSettingsSurviveTheConfigurationForm() throws Exception {
+ GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
+ assertTrue(config.isRedactSecrets(), "masking is on by default");
+ config.setRedactSecrets(false);
+ config.setRedactionPatterns("\\bbuild\\d+\\b");
+ config.setBlockWhenRedacted(true);
+
+ jenkins.configRoundtrip();
+
+ assertFalse(config.isRedactSecrets());
+ assertEquals("\\bbuild\\d+\\b", config.getRedactionPatterns());
+ assertTrue(config.isBlockWhenRedacted());
+ }
+
+ private FreeStyleBuild leakyFreestyleBuild() throws Exception {
+ FreeStyleProject project = jenkins.createFreeStyleProject();
+ project.getBuildersList().add(new TestBuilder() {
+ @Override
+ public boolean perform(hudson.model.AbstractBuild, ?> build, hudson.Launcher launcher,
+ hudson.model.BuildListener listener) {
+ listener.getLogger().println("git clone https://x-access-token:" + GITHUB_TOKEN
+ + "@github.com/o/r");
+ listener.getLogger().println("fatal: repository not found");
+ return false;
+ }
+ });
+ return jenkins.buildAndAssertStatus(Result.FAILURE, project);
+ }
+
+ private WebRequest previewRequest(FreeStyleBuild build) throws Exception {
+ return new WebRequest(new URL(jenkins.getURL(), build.getUrl() + "console-explain-error/preview"),
+ HttpMethod.POST);
+ }
+
+ private JSONObject postExplain(FreeStyleBuild build) throws Exception {
+ try (JenkinsRule.WebClient client = jenkins.createWebClient()) {
+ WebRequest request = new WebRequest(new URL(jenkins.getURL(), build.getUrl()
+ + "console-explain-error/explainConsoleError"), HttpMethod.POST);
+ return JSONObject.fromObject(client.getPage(request).getWebResponse().getContentAsString());
+ }
+ }
+
+ private WorkflowRun explain(String name, String step, String failure) throws Exception {
+ WorkflowJob job = jenkins.createProject(WorkflowJob.class, name);
+ job.setDefinition(new CpsFlowDefinition("node { try { " + failure + " } catch (e) { def r = " + step
+ + "; echo \"RETURNED=${r != null}\" } }", true));
+ WorkflowRun run = jenkins.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0));
+ assertNotNull(run);
+ return run;
+ }
+
+ private static final class RecordingUsageRecorder implements UsageRecorder {
+ private final List events = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void record(UsageEvent event) {
+ events.add(event);
+ }
+
+ List results() {
+ return events.stream().map(UsageEvent::result).toList();
+ }
+ }
+}