diff --git a/README.md b/README.md index e30056ca..6522b09b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ through a long console to find the one line that matters. - **Twelve AI providers**: Anthropic (Claude), AWS Bedrock, Azure OpenAI, Custom Okta AI, DeepSeek, Google Gemini, LangGraph Platform, Microsoft Foundry, Ollama, OpenAI, OpenAI Compatible (LiteLLM, OpenWebUI and other gateways) and Qwen, including private endpoints, company gateways and models you host yourself +- **Secrets masked before sending**: tokens, keys and passwords in the log are replaced with `[REDACTED]`, with + patterns of your own and a preview of what is sent. See [Masking secrets](docs/configuration.md#masking-secrets) - **Folder settings**, so each team can use its own provider, language and instructions - **Request quotas and usage metrics** to keep provider costs in check - **AI auto-fix** *(experimental)*: opens a pull request on GitHub, GitLab or Bitbucket with a proposed fix @@ -64,6 +66,8 @@ Open the console of a failed build and click **Explain Error**. The explanation at the top: a title that names the root cause, a summary, the steps to fix it, the log lines where it failed and, when there are any, practices that would have prevented it. It reads up to 200 lines of the steps that failed, or the global **Max Log Lines** when that is higher, and only the lines the global **Log Pattern** matches when one is set. +**Preview what is sent**, next to the button, shows those lines as the provider would get them, with secrets masked, +without sending anything. Once a build has an explanation, the button reads **Show explanation**, and the card says when the explanation was generated; the re-explain icon asks the provider again. If a request fails, the card stays open with what went wrong diff --git a/docs/configuration.md b/docs/configuration.md index d140ace6..2c4a9a7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,8 +20,70 @@ folder that sets it), then global. | **Automatically explain failed builds** | Explains every failed build without a pipeline change. See [Automatic Explanation](auto-explain.md) | Off | | **Max Log Lines** | Log lines an automatic explanation sends, shared out among the steps that failed so that each failed parallel branch is in. The Explain Error button sends at least 200; an `explainError()` step sets its own with `maxLines`. Raise it for models with large context windows | `100` | | **Log Pattern** | Regular expression, matched without regard to case. Only the 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 without a `logPattern` of their own | Empty | +| **Mask secrets before sending** | Replaces tokens, keys and passwords with `[REDACTED]` in everything sent to the provider. See [Masking secrets](#masking-secrets) | On | +| **Additional Redaction Patterns** | Regular expressions of your own, one per line, whose matches are masked as well | Empty | +| **Do not send a log in which something was masked** | When something is masked, nothing is sent and nothing is explained | Off | | **Max Automatic Explanations per Hour** | Provider calls per hour for automatic explanations, on top of the request quota | `30` | +## Masking secrets + +Everything sent to the AI provider is masked first: the log lines, the Custom Context, the workspace files of +`includeWorkspaceContext` and what auto-fix sends. This happens for automatic explanations, the Explain Error button +on the console and Stages pages, and the `explainError()` step alike. Jenkins already masks the credentials a build +binds with `withCredentials`; masking catches the secrets that never went through a credential, such as a token in an +environment dump, a password in a clone URL or a key a script printed. + +With **Mask secrets before sending** on, these are replaced with `[REDACTED]`: + +| What | Example | Sent as | +|---|---|---| +| AWS access key IDs | `AKIA…` | `[REDACTED]` | +| GitHub, GitLab, Slack, Google, Stripe, OpenAI, Anthropic and npm tokens | `ghp_…`, `github_pat_…`, `glpat-…`, `xoxb-…`, `AIza…`, `sk_live_…`, `sk-proj-…`, `sk-ant-…`, `npm_…` | `[REDACTED]` | +| JSON Web Tokens | `eyJ….eyJ….…` | `[REDACTED]` | +| Private keys, PEM and PGP, from their first line to their last, whatever comes before each line (a timestamp, a BuildKit step) and also inside a JSON string. A key whose first or last line is not in the lines sent is masked as far as its base64 lines go, when nothing comes before them; a key header printed on its own, as a secret scanner does, is masked alone | `-----BEGIN … PRIVATE KEY-----` to `-----END … PRIVATE KEY-----` | `[REDACTED]` | +| The credentials of an `Authorization` header or a bearer token | `Authorization: Bearer abc123…` | `Authorization: Bearer [REDACTED]` | +| The password of a URL, or of `curl -u` (not `docker run -u 1000:1000`) | `https://deploy:s3cret@git.example.com`, `curl -u admin:s3cret` | `https://deploy:[REDACTED]@git.example.com` | +| A value given to a key whose last word is `password`, `passwd`, `passphrase`, `secret` or `token`, or `key` after `api`, `access`, `private`, `secret`, `account`, `client` and the like; a key in capitals may run the words together (`PGPASSWORD`). As `KEY=value`, `key: value`, JSON, XML or a command-line option | `DB_PASSWORD=hunter2`, `"clientSecret": "…"`, `…`, `--token=…`, `-Dgpg.passphrase=…`, `AccountKey=…` | `DB_PASSWORD=[REDACTED]` | + +The key stays, so the model still sees what the value was for. The key's last word has to be one of those, so +`TokenService.java:42`, `password_reset.py`, `io.jsonwebtoken:jjwt`, `token-service:` and `"max_tokens": 4096` are +left alone. In a sentence, a key of one word followed by a colon and a single word is not taken for a value: +`Invalid token: expired` says what went wrong, where `password: changeme` on a line of YAML is masked. A reference +such as `${PASSWORD}`, a value that says there is none (`null`, `undefined`, `none`) and a value Jenkins already +masked (`****`) are left as they are too. The rules only find secrets by +their shape or their name: a password printed on its own, with nothing to say what it is, is sent. + +**Additional Redaction Patterns** takes regular expressions of your own, one per line, for what only you know is +sensitive, such as internal host names, IP addresses or customer IDs. They apply even with the built-in rules off. A +pattern with a group named `secret` masks that group only; any other pattern masks its whole match. Lines starting +with `#` are left out. + +```text +# Internal build hosts +\b[a-z0-9-]+\.corp\.example\.com\b +# Private IP addresses +\b10(?:\.\d{1,3}){3}\b +# A customer ID, keeping its name +(?i)customer[_-]?id[ \t]*[:=][ \t]*(?\S+) +``` + +The explanation shows how many secrets were masked next to the number of lines analysed, and the build's REST API has +it as `redactedCount`. An `explainError()` step also says it in the build's console (`[explain-error] Masked 2 secrets +in what is sent to the AI provider.`); the Explain Error button and automatic explanations run after the build and +leave its console alone. At `FINE`, the `io.jenkins.plugins.explain_error.ErrorExplainer` logger records each +request: the job and build, the number of lines sent, the provider and model, and how many secrets were masked. + +**Do not send a log in which something was masked** is for those who would rather have no explanation than trust the +masking: when anything is masked, in the log or in the context, nothing is sent and no quota is used. The +`explainError()` step returns `null` and says why in the console, the Explain Error button shows why, and an automatic +explanation leaves the build without one, saying why at `FINE` in the system log. Usage metrics count these requests +as `blocked`. + +**Preview what is sent**, next to the Explain Error button on the console page, shows as plain text the log lines a +new explanation would send, masked, without sending anything or using the quota. Anyone who can read the build's log +can open it. The Custom Context is sent with those lines and masked with them; the preview counts what is masked in +it and whether that would stop the request, but does not show it. + ## Folder settings Teams can use their own provider, model, language and instructions. In a folder's **Configure** page, check **Set AI @@ -74,6 +136,10 @@ unclassified: # enableAutoExplainOnFailure: true # Optional, explain failed builds without pipeline changes # autoExplainMaxLogLines: 100 # Log lines sent per build # logPattern: "(?i)(error|failed)" # Optional, only the lines it matches are sent + # redactSecrets: true # Mask tokens, keys and passwords before sending + # redactionPatterns: | # Optional, more patterns to mask, one per line + # \b[a-z0-9-]+\.corp\.example\.com\b + # blockWhenRedacted: false # Send nothing when something was masked # autoExplainMaxPerHour: 30 # Provider calls per hour for automatic explanations ``` diff --git a/docs/pipeline-step.md b/docs/pipeline-step.md index 6ca8ddf9..eed90184 100644 --- a/docs/pipeline-step.md +++ b/docs/pipeline-step.md @@ -27,8 +27,9 @@ sidebar: ![The AI Error Explanation page of a build](images/side-panel.png) -A failure to explain (explanation turned off, provider misconfigured, quota exceeded, provider error) is reported in the -console and never fails the build. +A failure to explain (explanation turned off, provider misconfigured, quota exceeded, provider error, or a secret +masked while [Do not send a log in which something was masked](configuration.md#masking-secrets) is on) is reported in +the console and never fails the build. ## Return value @@ -135,7 +136,8 @@ explainError( The files matching `workspaceContextPaths` (the default list when it is empty) are read, each cut to 8,000 bytes. Paths are skipped when the file name starts with `.env`, `credentials` or `secrets`, or when they lie under `.git/`, `.gradle/`, `target/`, `build/`, `dist/` or `node_modules/`, and absolute paths and `..` are refused. The files are sent -to the AI provider, so leave out any that hold secrets. +to the AI provider, [masked](configuration.md#masking-secrets) as the log is, but masking only finds secrets of a +known shape, so leave out any files that hold secrets. The files travel with the step's `customContext`. While `includeWorkspaceContext` is on and the step sets no `customContext`, the folder and global Custom Context are not sent; set `customContext` on the step to keep them. diff --git a/docs/usage-quota.md b/docs/usage-quota.md index 133d9eb2..5e18c4bb 100644 --- a/docs/usage-quota.md +++ b/docs/usage-quota.md @@ -148,7 +148,7 @@ The plugin exports the following metric families: | Metric | Type | Meaning | |---|---|---| -| `explain_error.requests..` | Counter | Total requests by entry point (`pipeline_step`, `console_action`, `run_listener`) and outcome (`success`, `provider_error`, `quota_rejected`, `throttled`, etc.) | +| `explain_error.requests..` | Counter | Total requests by entry point (`pipeline_step`, `console_action`, `run_listener`) and outcome (`success`, `provider_error`, `quota_rejected`, `throttled`, `blocked` when nothing was sent because a secret was masked, etc.) | | `explain_error.provider_calls...` | Counter | Provider/model-level outcomes for calls that involve a provider (`success`, `cache_hit`, `provider_error`, `quota_rejected`) | | `explain_error.request_duration_ms` | Histogram | End-to-end request duration in milliseconds | | `explain_error.input_log_lines` | Histogram | Number of input log lines processed per request | diff --git a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java index ade4b1ee..3d56ba41 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java @@ -152,6 +152,41 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } } + /** + * Shows, as plain text, what the Explain Error button would send to the AI provider for this build: the + * log lines, with secrets masked. Nothing is sent and no quota is used. Anyone who can read the build's + * log can read this. POST only, as it reads and masks as many lines as the button may send. + */ + @RequirePOST + public void doPreview(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + run.checkPermission(Item.READ); + int maxLines = maxLines(req.getParameter("maxLines")); + String log = String.join("\n", pageExtractor(run, maxLines).getFailedStepLog()); + LogRedactor redactor = LogRedactor.fromConfiguration(); + String sent = redactor.redact(log); + int maskedInLog = redactor.count(); + // The custom context goes with the log and is masked with it, but it is the administrator's to read + redactor.redact(new ErrorExplainer().inheritedCustomContext(run)); + int masked = redactor.count(); + rsp.setContentType("text/plain;charset=UTF-8"); + PrintWriter writer = rsp.getWriter(); + writer.println("# What the Explain Error button would send to the AI provider for this build."); + writer.println("# " + ErrorExplainer.countLines(sent) + " log lines. " + + (masked > 0 ? ErrorExplainer.describeRedaction(masked) + : redactor.isActive() ? "Nothing to mask was found." : "Masking is turned off.")); + if (masked > maskedInLog) { + writer.println("# " + (masked - maskedInLog) + " of them in the custom context, which is not shown here."); + } + if (masked > 0 && GlobalConfigurationImpl.get().isBlockWhenRedacted()) { + writer.println("# Nothing would be sent: the configuration says not to send a log in which" + + " something was masked."); + } + writer.println("# The custom context configured for the job is sent with it, masked the same way."); + writer.println(); + writer.print(sent); + writer.flush(); + } + /** * The number of log lines asked for, within {@link #MAX_LINES_LIMIT}; the default when the request does * not say or says something that is not a number. diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 666d94ed..9d56da31 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -35,6 +35,10 @@ public class ErrorExplainer { private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName()); + /** Said when nothing is sent because something was masked and the configuration says not to send it. */ + static final String BLOCKED = "Nothing was sent to the AI provider: the configuration says not to send a log" + + " in which something was masked."; + /** What the Explain Error button gets back when explanation is turned off for the job. */ static final String TURNED_OFF = "AI error explanation is turned off for this job."; @@ -179,6 +183,21 @@ String explainError(Run run, TaskListener listener, String logPattern, int Double effectiveTemperature = resolveEffectiveTemperature(run, stepTemperature); logToConsole(listener, "Temperature: " + (effectiveTemperature != null ? effectiveTemperature : "unset (provider default)") + "."); + // Secrets are masked before the text is compared, kept or sent + LogRedactor redactor = LogRedactor.fromConfiguration(); + errorLogs = redactor.redact(errorLogs); + effectiveCustomContext = redactor.redact(effectiveCustomContext); + int redactedCount = redactor.count(); + if (redactedCount > 0) { + logToConsole(listener, describeRedaction(redactedCount)); + if (GlobalConfigurationImpl.get().isBlockWhenRedacted()) { + logToConsole(listener, BLOCKED); + recordUsage(entryPoint, UsageEvent.Result.BLOCKED, provider, startTimeNanos, 0, + collectDownstreamLogs); + return null; + } + } + // An automatic explanation reuses the automatic explanation of an earlier build that failed // the same way. This comes before the quota: an explanation that is reused costs no provider // call. The step and the console button neither reuse nor leave a fingerprint to be matched. @@ -189,6 +208,7 @@ String explainError(Run run, TaskListener listener, String logPattern, int effectiveTemperature != null ? effectiveTemperature.toString() : null); ErrorExplanationAction reused = reuseMatchingExplanation(run, fingerprint, errorLogs); if (reused != null) { + reused.setRedactedCount(redactedCount); run.addOrReplaceAction(reused); logToConsole(listener, "Build #" + reused.getReusedFromBuild() + " failed the same way; reusing its explanation."); @@ -212,6 +232,7 @@ String explainError(Run run, TaskListener listener, String logPattern, int // Get AI explanation try { logToConsole(listener, "Sending AI request."); + LOGGER.fine(describeRequest(jobInfo, provider, inputLogLineCount, redactedCount)); this.providerCalled = true; JenkinsLogAnalysis analysis = provider.analyzeError(errorLogs, listener, effectiveLanguage, effectiveCustomContext, run != null ? run.getParent() : null, null, effectiveTemperature); @@ -233,6 +254,7 @@ String explainError(Run run, TaskListener listener, String logPattern, int action.setStructuredData(analysis); action.setLanguage(effectiveLanguage); action.setFailureFingerprint(fingerprint); + action.setRedactedCount(redactedCount); run.addOrReplaceAction(action); logToConsole(listener, buildSavedExplanationMessage(run, action)); recordUsage(entryPoint, UsageEvent.Result.SUCCESS, provider, startTimeNanos, inputLogLineCount, @@ -255,6 +277,18 @@ String explainError(Run run, TaskListener listener, String logPattern, int } } + /** The console line that says how much was masked. */ + static String describeRedaction(int count) { + return "Masked " + count + (count == 1 ? " secret" : " secrets") + " in what is sent to the AI provider."; + } + + /** The system log line that records a request: what was sent, to which model, and how much was masked. */ + private static String describeRequest(String jobInfo, BaseAIProvider provider, int lineCount, + int redactedCount) { + return jobInfo + " Sending " + lineCount + " log lines to " + provider.getProviderName() + " (" + + provider.getModel() + "); " + redactedCount + " masked."; + } + private PipelineLogExtractor.ExtractionResult extractErrorLogs(Run run, int maxLines, boolean collectDownstreamLogs, String downstreamJobPattern, @@ -355,6 +389,18 @@ ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull R throw new ExplanationException("error", "The provider is not properly configured."); } + // Secrets are masked before anything is sent; with nothing to be sent, no quota is used + LogRedactor redactor = LogRedactor.fromConfiguration(); + String text = redactor.redact(errorText); + String effectiveCustomContext = redactor.redact(resolveEffectiveCustomContext(run, null)); + int redactedCount = redactor.count(); + // A private key masked whole takes up one line; count what is sent, as the step does + inputLogLineCount = countLines(text); + if (redactedCount > 0 && GlobalConfigurationImpl.get().isBlockWhenRedacted()) { + recordUsage(entryPoint, UsageEvent.Result.BLOCKED, provider, startTimeNanos, 0, false); + throw new ExplanationException("warning", describeRedaction(redactedCount) + " " + BLOCKED); + } + // Check quota before making a real provider call (folder-level overrides global) QuotaCheckResult quotaCheck = tryAcquireQuota(run); if (!quotaCheck.allowed()) { @@ -364,22 +410,23 @@ ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull R } try { - // Resolve language, custom context, and temperature from folder/global + // Resolve language and temperature from folder/global String effectiveLanguage = resolveEffectiveLanguage(run, null); - String effectiveCustomContext = resolveEffectiveCustomContext(run, null); Double effectiveTemperature = resolveEffectiveTemperature(run, null); + LOGGER.fine(describeRequest(jobInfo, provider, inputLogLineCount, redactedCount)); // Get AI explanation with resolved settings - JenkinsLogAnalysis analysis = provider.analyzeError(errorText, new LogTaskListener(LOGGER, Level.FINE), + JenkinsLogAnalysis analysis = provider.analyzeError(text, new LogTaskListener(LOGGER, Level.FINE), effectiveLanguage, effectiveCustomContext, run.getParent(), null, effectiveTemperature); this.lastAnalysis = analysis; String explanation = analysis.toText(ExplanationLabels.forLanguage(effectiveLanguage)); LOGGER.fine(jobInfo + " AI error explanation succeeded."); LOGGER.fine("Explanation length: " + explanation.length()); - ErrorExplanationAction action = new ErrorExplanationAction(explanation, url, errorText, + ErrorExplanationAction action = new ErrorExplanationAction(explanation, url, text, provider.getProviderName(), provider.getModel(), inputLogLineCount); action.setStructuredData(analysis); action.setLanguage(effectiveLanguage); + action.setRedactedCount(redactedCount); run.addOrReplaceAction(action); run.save(); recordUsage(entryPoint, UsageEvent.Result.SUCCESS, provider, startTimeNanos, inputLogLineCount, false); diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java index 37e453cd..83f3783f 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java @@ -45,6 +45,8 @@ public class ErrorExplanationAction implements RunAction2 { private String failureFingerprint; /** Number of the build this explanation was taken over from, or 0 if it was generated for this build. */ private int reusedFromBuild; + /** How many secrets were masked in what was sent; 0 also for explanations of older plugin versions. */ + private int redactedCount; public ErrorExplanationAction(String explanation, String urlString, String originalErrorLogs, String providerName) { this(explanation, urlString, originalErrorLogs, providerName, null, @@ -127,6 +129,16 @@ public String getErrorSignature() { return errorSignature; } + /** How many secrets were masked in the log and context sent to the AI provider for this explanation. */ + @Exported(visibility = 1) + public int getRedactedCount() { + return redactedCount; + } + + void setRedactedCount(int redactedCount) { + this.redactedCount = Math.max(0, redactedCount); + } + void setLanguage(@CheckForNull String language) { this.language = language == null || language.isBlank() ? null : language.strip(); } diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java index 64494472..cd426918 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -332,8 +332,10 @@ protected Object run() throws Exception { Object result = toStepResult(explanation, explainer.getLastAnalysis()); if (step.isAutoFix()) { + // The log was masked when it was explained; the workspace files are masked here String errorLogs = explainer.getLastErrorLogs(); - String autoFixLogs = appendWorkspaceContext(errorLogs, workspaceContext); + String autoFixLogs = LogRedactor.fromConfiguration() + .redact(appendWorkspaceContext(errorLogs, workspaceContext)); BaseAIProvider provider = explainer.getResolvedProvider(run); if (errorLogs == null) { diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplanationLabels.java b/src/main/java/io/jenkins/plugins/explain_error/ExplanationLabels.java index b4cda771..705ea3ac 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplanationLabels.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplanationLabels.java @@ -124,6 +124,12 @@ public String linesAnalysed(int count) { return MessageFormat.format(bundle.getString("linesAnalysed"), String.valueOf(count)); } + /** "n secrets masked", for the line under the explanation of a log in which secrets were masked. */ + public String secretsMasked(int count) { + return MessageFormat.format(bundle.getString(count == 1 ? "secretMasked" : "secretsMasked"), + String.valueOf(count)); + } + /** Says which earlier build an explanation was taken over from. */ public String reusedFrom(int buildNumber) { return MessageFormat.format(bundle.getString("reusedFrom"), String.valueOf(buildNumber)); diff --git a/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java b/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java index 9ff7b319..fe0c80af 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java +++ b/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java @@ -11,6 +11,8 @@ import io.jenkins.plugins.explain_error.provider.LangGraphProvider; import io.jenkins.plugins.explain_error.provider.OllamaProvider; import io.jenkins.plugins.explain_error.provider.OpenAIProvider; +import java.util.List; +import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import jenkins.model.GlobalConfiguration; import jenkins.model.Jenkins; @@ -46,6 +48,9 @@ public class GlobalConfigurationImpl extends GlobalConfiguration { private boolean enableAutoExplainOnFailure = false; private int autoExplainMaxLogLines = 100; private String logPattern; + private boolean redactSecrets = true; + private String redactionPatterns; + private boolean blockWhenRedacted; private int autoExplainMaxPerHour = DEFAULT_AUTO_EXPLAIN_MAX_PER_HOUR; private transient QuotaEnforcer quotaEnforcer; @@ -183,6 +188,47 @@ public void setLogPattern(@CheckForNull String logPattern) { this.logPattern = Util.fixEmpty(logPattern); } + /** + * Whether the built-in rules mask secrets (tokens, keys, passwords) in what is sent to the AI provider. + * On by default; the administrator's own {@link #getRedactionPatterns() patterns} apply either way. + */ + public boolean isRedactSecrets() { + return redactSecrets; + } + + @DataBoundSetter + public void setRedactSecrets(boolean redactSecrets) { + this.redactSecrets = redactSecrets; + } + + /** + * Regular expressions of the administrator's own, one per line, whose matches are masked in what is + * sent to the AI provider. A pattern with a group named {@code secret} masks that group only. Blank lines + * and lines starting with {@code #} are left out. + */ + @CheckForNull + public String getRedactionPatterns() { + return redactionPatterns; + } + + @DataBoundSetter + public void setRedactionPatterns(@CheckForNull String redactionPatterns) { + this.redactionPatterns = Util.fixEmptyAndTrim(redactionPatterns); + } + + /** + * Whether nothing is sent when something was masked, instead of sending what is left. For those who + * would rather go without an explanation than trust the masking. + */ + public boolean isBlockWhenRedacted() { + return blockWhenRedacted; + } + + @DataBoundSetter + public void setBlockWhenRedacted(boolean blockWhenRedacted) { + this.blockWhenRedacted = blockWhenRedacted; + } + /** * Maximum number of AI provider calls made for automatic explanations per hour. A failed * build that was counted but never reached the provider gives its slot back. @@ -319,6 +365,21 @@ public FormValidation doCheckLogPattern(@QueryParameter String value) { } } + @POST + public FormValidation doCheckRedactionPatterns(@QueryParameter String value) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + List 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}+)>"); + + /** 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" + + "\nx\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(); + } + } +}