From bd2685bb82c99eb659297da3b1b37bc45e4ed914 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 29 May 2026 19:04:00 +0300 Subject: [PATCH] feat: Implement enterprise data protection features for log sanitization and payload auditing - Added log sanitization to redact sensitive information before sending to AI providers. - Introduced options for previewing sanitized payloads before sending. - Implemented auditing of sent payloads with redaction and dropped line counts. - Added regex policies for allowing and denying specific payload lines. - Updated configuration UI to support new features. - Enhanced ErrorExplainer and related classes to handle new sanitization logic. - Added tests for log sanitization and configuration validation. --- README.md | 13 +- docs/data-protection.md | 40 +++++ .../ConsoleExplainErrorAction.java | 53 ++++++- .../explain_error/ConsolePageDecorator.java | 4 + .../plugins/explain_error/ErrorExplainer.java | 88 +++++++++-- .../explain_error/ErrorExplanationAction.java | 30 ++++ .../explain_error/ExplainErrorStep.java | 5 +- .../GlobalConfigurationImpl.java | 78 +++++++++ .../plugins/explain_error/LogSanitizer.java | 149 ++++++++++++++++++ .../ConsolePageDecorator/footer.jelly | 3 +- .../ErrorExplanationAction/index.jelly | 6 + .../GlobalConfigurationImpl/config.jelly | 19 +++ src/main/webapp/js/explain-error-footer.js | 59 ++++++- .../explain_error/ErrorExplainerTest.java | 46 ++++++ .../GlobalConfigurationImplTest.java | 19 +++ .../explain_error/LogSanitizerTest.java | 84 ++++++++++ 16 files changed, 677 insertions(+), 19 deletions(-) create mode 100644 docs/data-protection.md create mode 100644 src/main/java/io/jenkins/plugins/explain_error/LogSanitizer.java create mode 100644 src/test/java/io/jenkins/plugins/explain_error/LogSanitizerTest.java diff --git a/README.md b/README.md index e010228d..43f0f341 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Whether it’s a compilation error, test failure, or deployment hiccup, this plu * **One-click error analysis** on any console output * **Pipeline-ready** with a simple `explainError()` step * **Workspace Context** *(opt-in)* — include selected workspace files for more accurate explanations +* **Enterprise data protection** — sanitize logs before LLM calls, preview console payloads before sending, audit sent payloads, and enforce allow/deny regex policies * **AI auto-fix** *(experimental)* — automatically opens a pull request on GitHub, GitLab, or Bitbucket with AI-generated code changes when a build fails * **AI-powered explanations** via Anthropic Claude, AWS Bedrock, Azure OpenAI, DeepSeek, Google Gemini, Microsoft Foundry, Ollama, OpenAI GPT models, Qwen, or generic Okta-authenticated company AI gateways * **Folder-level configuration** so teams can use project-specific settings @@ -81,9 +82,16 @@ Whether it’s a compilation error, test failure, or deployment hiccup, this plu | **API URL** | AI service endpoint | **Leave empty** for official APIs where supported. **Required for Custom Okta AI and Ollama providers.** Optional Bedrock Runtime endpoint override for private VPC endpoints. | | **AI Model** | Model to use for analysis | *Required*. Specify the model name offered by your selected AI provider | | **Custom Context** | Additional instructions or context for the AI (e.g., KB article links, organization-specific troubleshooting steps) | *Optional*. Can be overridden at the job level. | +| **Enable Log Sanitization** | Redact secrets, internal URLs, private repo URLs, local paths, and customer email addresses before provider calls | ✅ Enabled | +| **Preview Payload Before Sending** | Require console action users to review the sanitized payload before it is sent | Disabled | +| **Audit Sent Payload** | Store the sanitized log payload with the build explanation action | ✅ Enabled | +| **Payload Allow Regex** | Optional allow policy; only matching payload lines are sent | Empty | +| **Payload Deny Regex** | Optional deny policy; matching text is replaced with `[REDACTED_SECRET]` | Empty | `Custom Okta AI` adds provider-specific fields for `Okta Token URL`, `Client ID`, `Client Secret`, and optional `Scope`, `API Version`, `App Key`, and custom access-token header settings. This is intended for generic company AI gateways that require an OAuth client-credentials exchange before the chat call. +See [Enterprise Data Protection](docs/data-protection.md) for sanitizer, preview, audit, and regex policy details. + 4. Click **"Test Configuration"** to verify your setup 5. Save the configuration @@ -534,8 +542,9 @@ Enable debug logs: 1. Use `explainError()` in `post { failure { ... } }` blocks 2. Apply `logPattern` to focus on relevant errors -3. Monitor usage metrics and quota outcomes to control costs (see [AI Provider Call Quotas](docs/usage-quota.md)) -4. Keep plugin updated regularly +3. Keep log sanitization enabled and review sent payload audits for sensitive jobs +4. Monitor usage metrics and quota outcomes to control costs (see [AI Provider Call Quotas](docs/usage-quota.md)) +5. Keep plugin updated regularly ## Support & Community diff --git a/docs/data-protection.md b/docs/data-protection.md new file mode 100644 index 00000000..b44d1b81 --- /dev/null +++ b/docs/data-protection.md @@ -0,0 +1,40 @@ +# Enterprise Data Protection + +Explain Error sanitizes data before sending build failure context to an AI provider. + +## What is protected + +Log sanitization is enabled by default and replaces matched sensitive values with: + +```text +[REDACTED_SECRET] +``` + +The default rules redact common credential assignments, bearer/basic authorization headers, GitHub and AWS-style tokens, private key blocks, internal URLs, private repository URLs, local artifact/workspace paths, and email addresses. + +## Preview before sending + +Enable **Preview Payload Before Sending** in global configuration to require console action users to review the sanitized payload before the provider call starts. The preview shows line count, redaction count, dropped line count, and the sanitized payload excerpt. + +Pipeline steps are non-interactive, so they sanitize and audit automatically instead of prompting. + +## Audit what was sent + +When **Audit Sent Payload** is enabled, the build's **AI Error Explanation** action stores the sanitized log payload that was sent to the provider, along with redaction and dropped-line counts. The original raw logs are not persisted by this audit field. + +Disable this option if your organization does not want sanitized payloads stored on build records. + +## Allow and deny regex policies + +Use **Payload Allow Regex** to send only matching payload lines. Non-matching lines are dropped before redaction. + +Use **Payload Deny Regex** to redact organization-specific sensitive text that is not covered by the default rules. + +Examples: + +```text +Payload Allow Regex: (?i)(error|failed|exception|caused by) +Payload Deny Regex: (?i)(customer-[0-9]+|tenant-[a-z0-9-]+) +``` + +Invalid regular expressions are rejected on the configuration page. 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 c620bc4d..d22c4b50 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java @@ -92,11 +92,7 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } // Optionally allow maxLines as a parameter, default to 200 - int maxLines = 200; - String maxLinesParam = req.getParameter("maxLines"); - if (maxLinesParam != null) { - try { maxLines = Integer.parseInt(maxLinesParam); } catch (NumberFormatException ignore) {} - } + int maxLines = parseMaxLines(req); // Fetch the last N lines of the log PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines, Jenkins.getAuthentication2(), @@ -120,6 +116,40 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } } + /** + * AJAX endpoint to preview the sanitized payload before sending it to the AI provider. + */ + @RequirePOST + public void doPreviewConsolePayload(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + try { + run.checkPermission(hudson.model.Item.READ); + + int maxLines = parseMaxLines(req); + PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines, Jenkins.getAuthentication2(), + false, null); + List logLines = logExtractor.getFailedStepLog(); + this.urlString = logExtractor.getUrl(); + + ErrorExplainer explainer = new ErrorExplainer(); + LogSanitizer.SanitizedPayload sanitizedPayload = explainer.previewPayload(String.join("\n", logLines)); + + rsp.setContentType("application/json"); + rsp.setCharacterEncoding("UTF-8"); + PrintWriter writer = rsp.getWriter(); + JSONObject json = new JSONObject(); + json.put("status", "success"); + json.put("message", sanitizedPayload.text()); + json.put("redactionCount", sanitizedPayload.redactionCount()); + json.put("droppedLineCount", sanitizedPayload.droppedLineCount()); + json.put("sentLineCount", sanitizedPayload.sentLineCount()); + writer.write(json.toString()); + writer.flush(); + } catch (Exception e) { + LOGGER.severe("Error previewing console payload: " + e.getMessage()); + writeJsonResponse(rsp, "error", "Unknown", "Error: " + e.getMessage()); + } + } + /** * AJAX endpoint to check build status. * Returns JSON with buildingStatus to determine if button should be shown. 0 - SUCCESS, 1 - RUNNING, 2 - FINISHED and FAILURE @@ -167,6 +197,19 @@ private void writeJsonResponse(StaplerResponse2 rsp, String status, String provi writer.flush(); } + private int parseMaxLines(StaplerRequest2 req) { + int maxLines = 200; + String maxLinesParam = req.getParameter("maxLines"); + if (maxLinesParam != null) { + try { + maxLines = Integer.parseInt(maxLinesParam); + } catch (NumberFormatException ignore) { + // Keep the default when the optional parameter is invalid. + } + } + return maxLines; + } + /** * Create a response indicating this is a cached result. * @param explanation The cached explanation diff --git a/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java b/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java index b7807659..f90ece39 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java @@ -38,6 +38,10 @@ public String getProviderName() { return GlobalConfigurationImpl.get().getAiProvider().getProviderName(); } + public final boolean isPayloadPreviewEnabled() { + return GlobalConfigurationImpl.get().isEnablePayloadPreview(); + } + /** * Helper method used by jelly to checked if we're on a console url. */ 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 4833cd80..6c43772e 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -15,6 +15,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import jenkins.model.Jenkins; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; @@ -31,6 +32,7 @@ public class ErrorExplainer { private String urlString; private String lastErrorLogs; private final UsageRecorder usageRecorder; + private final LogSanitizer logSanitizer = new LogSanitizer(); private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName()); @@ -143,18 +145,21 @@ String explainError(Run run, TaskListener listener, String logPattern, int PipelineLogExtractor.ExtractionResult extractionResult = extractErrorLogs(run, maxLines, collectDownstreamLogs, downstreamJobPattern, authentication); String errorLogs = filterErrorLogs(extractionResult.logLines(), logPattern); - this.lastErrorLogs = errorLogs; - inputLogLineCount = countLines(errorLogs); logExtractionSummary(listener, extractionResult, maxLines); // Use step-level customContext if provided, otherwise fallback to global String effectiveCustomContext = StringUtils.isNotBlank(customContext) ? customContext : GlobalConfigurationImpl.get().getCustomContext(); logToConsole(listener, "Custom context source: " + resolveCustomContextSource(customContext) + "."); + PreparedPayload preparedPayload = preparePayload(errorLogs, effectiveCustomContext); + this.lastErrorLogs = preparedPayload.errorLogs().text(); + inputLogLineCount = preparedPayload.errorLogs().sentLineCount(); + logSanitizationSummary(listener, preparedPayload); // Get AI explanation try { logToConsole(listener, "Sending AI request."); - String explanation = provider.explainError(errorLogs, listener, language, effectiveCustomContext, + String explanation = provider.explainError(preparedPayload.errorLogs().text(), listener, language, + preparedPayload.customContext().text(), run != null ? run.getParent() : null, null); LOGGER.fine(jobInfo + " AI error explanation succeeded."); logToConsole(listener, "AI request completed successfully."); @@ -167,8 +172,8 @@ String explainError(Run run, TaskListener listener, String logPattern, int } // Store explanation in build action - ErrorExplanationAction action = new ErrorExplanationAction(explanation, urlString, errorLogs, - provider.getProviderName(), provider.getModel(), inputLogLineCount); + ErrorExplanationAction action = createExplanationAction(explanation, errorLogs, preparedPayload, + provider); run.addOrReplaceAction(action); logToConsole(listener, buildSavedExplanationMessage(run, action)); recordUsage(entryPoint, UsageEvent.Result.SUCCESS, provider, startTimeNanos, inputLogLineCount, @@ -188,6 +193,10 @@ String explainError(Run run, TaskListener listener, String logPattern, int LOGGER.severe(jobInfo + " Failed to explain error: " + e.getMessage()); logToConsole(listener, "Failed to explain error: " + e.getMessage()); return null; + } catch (PatternSyntaxException e) { + LOGGER.severe(jobInfo + " Invalid payload protection regex: " + e.getMessage()); + logToConsole(listener, "Invalid payload protection regex: " + e.getDescription()); + return null; } } @@ -249,6 +258,7 @@ ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull R UsageEvent.EntryPoint entryPoint) throws IOException, ExplanationException { String jobInfo ="[" + run.getParent().getFullName() + " #" + run.getNumber() + "]"; + this.urlString = url; long startTimeNanos = System.nanoTime(); int inputLogLineCount = countLines(errorText); ProviderResolution providerResolution = resolveProvider(run); @@ -283,12 +293,14 @@ ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull R try { // Get AI explanation with global custom context - String explanation = provider.explainError(errorText, new LogTaskListener(LOGGER, Level.FINE), null, - GlobalConfigurationImpl.get().getCustomContext(), run.getParent(), null); + PreparedPayload preparedPayload = preparePayload(errorText, GlobalConfigurationImpl.get().getCustomContext()); + inputLogLineCount = preparedPayload.errorLogs().sentLineCount(); + String explanation = provider.explainError(preparedPayload.errorLogs().text(), + new LogTaskListener(LOGGER, Level.FINE), null, preparedPayload.customContext().text(), + run.getParent(), null); LOGGER.fine(jobInfo + " AI error explanation succeeded."); LOGGER.fine("Explanation length: " + explanation.length()); - ErrorExplanationAction action = new ErrorExplanationAction(explanation, url, errorText, - provider.getProviderName(), provider.getModel(), inputLogLineCount); + ErrorExplanationAction action = createExplanationAction(explanation, errorText, preparedPayload, provider); run.addOrReplaceAction(action); run.save(); recordUsage(entryPoint, UsageEvent.Result.SUCCESS, provider, startTimeNanos, inputLogLineCount, false); @@ -301,6 +313,60 @@ ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull R } } + public final LogSanitizer.SanitizedPayload previewPayload(String errorText) { + PreparedPayload preparedPayload = preparePayload(errorText, GlobalConfigurationImpl.get().getCustomContext()); + return new LogSanitizer.SanitizedPayload(buildAuditedPayload(preparedPayload), + totalRedactions(preparedPayload), totalDroppedLines(preparedPayload), + preparedPayload.errorLogs().sentLineCount()); + } + + final LogSanitizer.SanitizedPayload sanitizeForProvider(String payload) { + return logSanitizer.sanitize(payload, GlobalConfigurationImpl.get().getLogSanitizerPolicy()); + } + + private PreparedPayload preparePayload(String errorLogs, String customContext) { + LogSanitizer.Policy policy = GlobalConfigurationImpl.get().getLogSanitizerPolicy(); + return new PreparedPayload(logSanitizer.sanitize(errorLogs, policy), + logSanitizer.sanitize(customContext, policy)); + } + + private ErrorExplanationAction createExplanationAction(String explanation, String originalErrorLogs, + PreparedPayload preparedPayload, + BaseAIProvider provider) { + String auditedPayload = GlobalConfigurationImpl.get().isAuditSentPayload() + ? buildAuditedPayload(preparedPayload) : null; + return new ErrorExplanationAction(explanation, urlString, originalErrorLogs, auditedPayload, + provider.getProviderName(), provider.getModel(), preparedPayload.errorLogs().sentLineCount(), + totalRedactions(preparedPayload), totalDroppedLines(preparedPayload)); + } + + private String buildAuditedPayload(PreparedPayload preparedPayload) { + String payload = preparedPayload.errorLogs().text(); + if (StringUtils.isBlank(preparedPayload.customContext().text())) { + return payload; + } + return payload + "\n\n--- CUSTOM CONTEXT SENT TO AI ---\n" + preparedPayload.customContext().text(); + } + + private int totalRedactions(PreparedPayload preparedPayload) { + return preparedPayload.errorLogs().redactionCount() + preparedPayload.customContext().redactionCount(); + } + + private int totalDroppedLines(PreparedPayload preparedPayload) { + return preparedPayload.errorLogs().droppedLineCount() + preparedPayload.customContext().droppedLineCount(); + } + + private void logSanitizationSummary(TaskListener listener, PreparedPayload preparedPayload) { + if (!GlobalConfigurationImpl.get().isEnableLogSanitization()) { + logToConsole(listener, "Log sanitization is disabled."); + return; + } + + logToConsole(listener, "Sanitized AI payload; redacted " + totalRedactions(preparedPayload) + + " sensitive value(s), dropped " + totalDroppedLines(preparedPayload) + + " line(s) by allow policy."); + } + /** * Resolve the AI provider to use for error explanation. * Resolution order: @@ -514,6 +580,10 @@ static int countLines(String text) { return lineCount; } + private record PreparedPayload(LogSanitizer.SanitizedPayload errorLogs, + LogSanitizer.SanitizedPayload customContext) { + } + private record ProviderResolution(@CheckForNull BaseAIProvider provider, String sourceLabel) { } } 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 fd23bf03..e1062c94 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java @@ -15,7 +15,10 @@ public class ErrorExplanationAction implements RunAction2 { private final String explanation; private final String urlString; private final transient String originalErrorLogs; + private final String sentErrorLogs; private final int inputLogLineCount; + private final int redactionCount; + private final int droppedLineCount; private final long timestamp; private String providerName = "Unknown"; private String providerModel = "Unknown"; @@ -28,13 +31,22 @@ public ErrorExplanationAction(String explanation, String urlString, String origi public ErrorExplanationAction(String explanation, String urlString, String originalErrorLogs, String providerName, String providerModel, int inputLogLineCount) { + this(explanation, urlString, originalErrorLogs, null, providerName, providerModel, inputLogLineCount, 0, 0); + } + + public ErrorExplanationAction(String explanation, String urlString, String originalErrorLogs, + String sentErrorLogs, String providerName, String providerModel, + int inputLogLineCount, int redactionCount, int droppedLineCount) { this.explanation = explanation; this.originalErrorLogs = originalErrorLogs; + this.sentErrorLogs = sentErrorLogs; this.timestamp = System.currentTimeMillis(); this.providerName = providerName; this.providerModel = providerModel; this.urlString = urlString; this.inputLogLineCount = Math.max(0, inputLogLineCount); + this.redactionCount = Math.max(0, redactionCount); + this.droppedLineCount = Math.max(0, droppedLineCount); } public Object readResolve() { @@ -75,6 +87,10 @@ public String getOriginalErrorLogs() { return originalErrorLogs; } + public final String getSentErrorLogs() { + return sentErrorLogs; + } + @Exported(visibility = 1) public long getTimestamp() { return timestamp; @@ -105,6 +121,20 @@ public int getInputLogLineCount() { return inputLogLineCount; } + @Exported(visibility = 1) + public final int getRedactionCount() { + return redactionCount; + } + + @Exported(visibility = 1) + public final int getDroppedLineCount() { + return droppedLineCount; + } + + public final boolean hasSentPayloadAudit() { + return sentErrorLogs != null && !sentErrorLogs.isBlank(); + } + @Override public void onAttached(Run r) { this.run = r; 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 ecc823c9..966edd78 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -291,7 +291,6 @@ protected String run() throws Exception { if (step.isAutoFix()) { String errorLogs = explainer.getLastErrorLogs(); - String autoFixLogs = appendWorkspaceContext(errorLogs, workspaceContext); BaseAIProvider provider = explainer.getResolvedProvider(run); if (errorLogs == null) { @@ -303,6 +302,10 @@ protected String run() throws Exception { return explanation; } + String autoFixLogs = appendWorkspaceContext(errorLogs, workspaceContext); + LogSanitizer.SanitizedPayload sanitizedAutoFixLogs = explainer.sanitizeForProvider(autoFixLogs); + autoFixLogs = sanitizedAutoFixLogs.text(); + AutoFixOrchestrator orchestrator = new AutoFixOrchestrator(); List allowedPaths = Arrays.stream(step.getAutoFixAllowedPaths().split(",")) .map(String::trim) 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 5e5db082..da82e0b6 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java +++ b/src/main/java/io/jenkins/plugins/explain_error/GlobalConfigurationImpl.java @@ -35,6 +35,11 @@ public class GlobalConfigurationImpl extends GlobalConfiguration { private boolean enableQuota = false; private QuotaWindow quotaWindow = QuotaWindow.HOURLY; private int maxProviderCallsPerWindow = 100; + private Boolean enableLogSanitization = true; + private Boolean enablePayloadPreview = false; + private Boolean auditSentPayload = true; + private String payloadAllowRegex = ""; + private String payloadDenyRegex = ""; private transient QuotaEnforcer quotaEnforcer; @@ -173,6 +178,58 @@ public void setMaxProviderCallsPerWindow(int maxProviderCallsPerWindow) { this.maxProviderCallsPerWindow = Math.max(0, maxProviderCallsPerWindow); } + public final boolean isEnableLogSanitization() { + return enableLogSanitization == null || enableLogSanitization; + } + + @DataBoundSetter + public final void setEnableLogSanitization(boolean enableLogSanitization) { + this.enableLogSanitization = enableLogSanitization; + } + + public final boolean isEnablePayloadPreview() { + return Boolean.TRUE.equals(enablePayloadPreview); + } + + @DataBoundSetter + public final void setEnablePayloadPreview(boolean enablePayloadPreview) { + this.enablePayloadPreview = enablePayloadPreview; + } + + public final boolean isAuditSentPayload() { + return auditSentPayload == null || auditSentPayload; + } + + @DataBoundSetter + public final void setAuditSentPayload(boolean auditSentPayload) { + this.auditSentPayload = auditSentPayload; + } + + public final String getPayloadAllowRegex() { + return payloadAllowRegex != null ? payloadAllowRegex : ""; + } + + @DataBoundSetter + public final void setPayloadAllowRegex(String payloadAllowRegex) { + this.payloadAllowRegex = payloadAllowRegex != null ? payloadAllowRegex : ""; + } + + public final String getPayloadDenyRegex() { + return payloadDenyRegex != null ? payloadDenyRegex : ""; + } + + @DataBoundSetter + public final void setPayloadDenyRegex(String payloadDenyRegex) { + this.payloadDenyRegex = payloadDenyRegex != null ? payloadDenyRegex : ""; + } + + public final LogSanitizer.Policy getLogSanitizerPolicy() { + if (!isEnableLogSanitization()) { + return LogSanitizer.Policy.disabled(); + } + return LogSanitizer.Policy.enabled(getPayloadAllowRegex(), getPayloadDenyRegex()); + } + /** * Returns the singleton {@link QuotaEnforcer}, creating one lazily if needed * (e.g. after deserialization when {@code transient} fields are not restored). @@ -216,6 +273,27 @@ public FormValidation doCheckMaxProviderCallsPerWindow(@QueryParameter int value return FormValidation.ok(); } + @POST + public final FormValidation doCheckPayloadAllowRegex(@QueryParameter String value) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + return validatePayloadRegex(value); + } + + @POST + public final FormValidation doCheckPayloadDenyRegex(@QueryParameter String value) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + return validatePayloadRegex(value); + } + + private FormValidation validatePayloadRegex(String value) { + try { + LogSanitizer.validateRegex(value); + return FormValidation.ok(); + } catch (java.util.regex.PatternSyntaxException e) { + return FormValidation.error("Invalid regular expression: " + e.getDescription()); + } + } + @Override public String getDisplayName() { return "Explain Error Plugin Configuration"; diff --git a/src/main/java/io/jenkins/plugins/explain_error/LogSanitizer.java b/src/main/java/io/jenkins/plugins/explain_error/LogSanitizer.java new file mode 100644 index 00000000..3476aee9 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/LogSanitizer.java @@ -0,0 +1,149 @@ +package io.jenkins.plugins.explain_error; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import org.apache.commons.lang3.StringUtils; + +/** + * Redacts sensitive values before build logs or workspace context are sent to an AI provider. + */ +public final class LogSanitizer { + + public static final String REDACTION_TOKEN = "[REDACTED_SECRET]"; + + private static final List DEFAULT_SECRET_PATTERNS = List.of( + new RedactionRule(Pattern.compile("(?i)(\\b(?:password|passwd|pwd|secret|token|api[_-]?key|" + + "access[_-]?key|client[_-]?secret|license[_-]?key)\\b\\s*[:=]\\s*)([^\\s'\";]+)"), + true), + new RedactionRule(Pattern.compile("(?i)(authorization\\s*:\\s*bearer\\s+)[A-Za-z0-9._~+/=-]+"), + false), + new RedactionRule(Pattern.compile("(?i)(basic\\s+)[A-Za-z0-9+/=]{12,}"), false), + new RedactionRule(Pattern.compile("\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\\b"), false), + new RedactionRule(Pattern.compile("\\bAKIA[0-9A-Z]{16}\\b"), false), + new RedactionRule(Pattern.compile( + "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----"), false), + new RedactionRule(Pattern.compile("(?i)\\b(?:https?|ssh)://[^\\s'\"<>]*(?:internal|intranet|corp|local|" + + "localhost|127\\.0\\.0\\.1|10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|" + + "192\\.168\\.\\d{1,3}\\.\\d{1,3})[^\\s'\"<>]*"), false), + new RedactionRule(Pattern.compile("(?i)\\b(?:git@|ssh://git@)[^\\s'\"<>]+[:/][^\\s'\"<>]+\\.git\\b"), + false), + new RedactionRule( + Pattern.compile("(?i)(?:/Users|/home|/var/lib/jenkins|/private/var|C:\\\\Users)\\S+"), + false), + new RedactionRule(Pattern.compile("\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b", + Pattern.CASE_INSENSITIVE), false)); + + public SanitizedPayload sanitize(String payload, Policy policy) { + if (StringUtils.isBlank(payload)) { + return new SanitizedPayload("", 0, 0, 0); + } + + Policy effectivePolicy = policy != null ? policy : Policy.enabled("", ""); + if (!effectivePolicy.enabled()) { + return new SanitizedPayload(payload, 0, 0, countLines(payload)); + } + + String allowedPayload = applyAllowPolicy(payload, effectivePolicy.allowRegex()); + int droppedLines = countPolicyLines(payload) - countPolicyLines(allowedPayload); + + RedactionResult redacted = redact(allowedPayload, DEFAULT_SECRET_PATTERNS); + int redactionCount = redacted.redactionCount(); + if (StringUtils.isNotBlank(effectivePolicy.denyRegex())) { + redacted = redact(redacted.text(), List.of(new RedactionRule( + Pattern.compile(effectivePolicy.denyRegex()), false))); + redactionCount += redacted.redactionCount(); + } + + return new SanitizedPayload(redacted.text(), redactionCount, Math.max(0, droppedLines), + countLines(redacted.text())); + } + + private String applyAllowPolicy(String payload, String allowRegex) { + if (StringUtils.isBlank(allowRegex)) { + return payload; + } + + Pattern allowPattern = Pattern.compile(allowRegex); + String[] lines = payload.split("\\R"); + List allowedLines = new ArrayList<>(); + for (String line : lines) { + if (allowPattern.matcher(line).find()) { + allowedLines.add(line); + } + } + return String.join("\n", allowedLines); + } + + private RedactionResult redact(String payload, List rules) { + String redacted = payload; + int redactionCount = 0; + for (RedactionRule rule : rules) { + Matcher matcher = rule.pattern().matcher(redacted); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + redactionCount++; + String replacement = rule.preserveFirstGroup() && matcher.groupCount() >= 1 + ? Matcher.quoteReplacement(matcher.group(1) + REDACTION_TOKEN) + : REDACTION_TOKEN; + matcher.appendReplacement(buffer, replacement); + } + matcher.appendTail(buffer); + redacted = buffer.toString(); + } + return new RedactionResult(redacted, redactionCount); + } + + static int countLines(@CheckForNull String text) { + if (StringUtils.isBlank(text)) { + return 0; + } + + int lineCount = 1; + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '\n') { + lineCount++; + } + } + return lineCount; + } + + private int countPolicyLines(String text) { + if (StringUtils.isBlank(text)) { + return 0; + } + return text.split("\\R").length; + } + + public static void validateRegex(String regex) { + if (StringUtils.isNotBlank(regex)) { + try { + Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw e; + } + } + } + + public record Policy(boolean enabled, String allowRegex, String denyRegex) { + public static Policy enabled(String allowRegex, String denyRegex) { + return new Policy(true, StringUtils.defaultString(allowRegex), StringUtils.defaultString(denyRegex)); + } + + public static Policy disabled() { + return new Policy(false, "", ""); + } + } + + public record SanitizedPayload(String text, int redactionCount, int droppedLineCount, int sentLineCount) { + } + + private record RedactionRule(Pattern pattern, boolean preserveFirstGroup) { + } + + private record RedactionResult(String text, int redactionCount) { + } +} diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly index 0f32fcf5..59718916 100644 --- a/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly +++ b/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly @@ -18,7 +18,8 @@
+ data-has-explanation="${hasExplanation}" data-plugin-enabled="${enabled}" + data-payload-preview-enabled="${it.payloadPreviewEnabled}">
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly index 1ebda880..0d6114cc 100644 --- a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly +++ b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly @@ -14,6 +14,12 @@
+ + +

Redactions: ${it.redactionCount}; dropped by policy: ${it.droppedLineCount}; lines sent: ${it.inputLogLineCount}

+
${it.sentErrorLogs}
+
+
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 a392f0f0..7d46d38c 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 @@ -8,6 +8,25 @@ description="Additional instructions or context for the AI (e.g., KB article links, organization-specific troubleshooting steps). This can be overridden at the job level."> + + + + + + + + + + + + + + diff --git a/src/main/webapp/js/explain-error-footer.js b/src/main/webapp/js/explain-error-footer.js index 7e2d696b..b2e08193 100644 --- a/src/main/webapp/js/explain-error-footer.js +++ b/src/main/webapp/js/explain-error-footer.js @@ -159,6 +159,64 @@ function cancelExplanation() { } function sendExplainRequest(forceNew = false) { + const container = document.getElementById('explain-error-container'); + if (container.dataset.payloadPreviewEnabled === 'true') { + previewPayloadBeforeSend(forceNew); + return; + } + executeExplainRequest(forceNew); +} + +function previewPayloadBeforeSend(forceNew = false) { + const container = document.getElementById('explain-error-container'); + const basePath = container.dataset.runUrl + const rootURL = document.head.getAttribute("data-rooturl"); + const url = rootURL + '/' + basePath + 'console-explain-error/previewConsolePayload'; + + const headers = crumb.wrap({ + "Content-Type": "application/x-www-form-urlencoded", + }); + + showSpinner(); + + fetch(url, { + method: "POST", + headers: headers, + body: "" + }) + .then(parseJsonResponse) + .then(json => { + if (json.status !== "success") { + notificationBar.show(json.message, notificationBar.ERROR); + hideContainer(); + return; + } + + const preview = buildPayloadPreview(json); + if (window.confirm(preview)) { + executeExplainRequest(forceNew); + } else { + hideContainer(); + } + }) + .catch(error => { + notificationBar.show(`Error: ${error.message}`, notificationBar.ERROR); + hideContainer(); + }); +} + +function buildPayloadPreview(json) { + const payload = json.message || ''; + const limit = 4000; + const previewText = payload.length > limit + ? payload.substring(0, limit) + '\n\n...[truncated preview]...' + : payload; + return `Review the sanitized payload before sending it to the AI provider.\n\n` + + `Lines: ${json.sentLineCount}, redactions: ${json.redactionCount}, dropped by policy: ${json.droppedLineCount}\n\n` + + `${previewText}\n\nSend this payload?`; +} + +function executeExplainRequest(forceNew = false) { const container = document.getElementById('explain-error-container'); const basePath = container.dataset.runUrl const rootURL = document.head.getAttribute("data-rooturl"); @@ -168,7 +226,6 @@ function sendExplainRequest(forceNew = false) { "Content-Type": "application/x-www-form-urlencoded", }); - // Add forceNew parameter if needed const body = forceNew ? "forceNew=true" : ""; showSpinner(); diff --git a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java index 2b29df0b..5cd330e6 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java @@ -176,6 +176,52 @@ void testErrorExplainerTextMethods(JenkinsRule jenkins) throws Exception { assertEquals("API request failed: Request failed.", e.getMessage()); } + @Test + void explainErrorText_sanitizesPayloadBeforeProviderCallAndAuditsSentPayload(JenkinsRule jenkins) + throws Exception { + ErrorExplainer errorExplainer = new ErrorExplainer(); + GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); + config.setEnableExplanation(true); + config.setEnableLogSanitization(true); + config.setAuditSentPayload(true); + config.setCustomContext(""); + TestProvider provider = new TestProvider(); + config.setAiProvider(provider); + + FreeStyleProject project = jenkins.createFreeStyleProject(); + FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); + + ErrorExplanationAction action = errorExplainer.explainErrorText( + "ERROR token=abc123\npassword=secret-value", "", build); + + assertFalse(provider.getLastErrorLogs().contains("abc123")); + assertFalse(provider.getLastErrorLogs().contains("secret-value")); + assertTrue(provider.getLastErrorLogs().contains(LogSanitizer.REDACTION_TOKEN)); + assertEquals(provider.getLastErrorLogs(), action.getSentErrorLogs()); + assertEquals(2, action.getRedactionCount()); + assertEquals(0, action.getDroppedLineCount()); + } + + @Test + void explainErrorText_sanitizesCustomContextBeforeProviderCall(JenkinsRule jenkins) + throws Exception { + ErrorExplainer errorExplainer = new ErrorExplainer(); + GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); + config.setEnableExplanation(true); + config.setEnableLogSanitization(true); + config.setCustomContext("internal token=context-secret"); + TestProvider provider = new TestProvider(); + config.setAiProvider(provider); + + FreeStyleProject project = jenkins.createFreeStyleProject(); + FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); + + errorExplainer.explainErrorText("Build failed", "", build); + + assertFalse(provider.getLastCustomContext().contains("context-secret")); + assertTrue(provider.getLastCustomContext().contains(LogSanitizer.REDACTION_TOKEN)); + } + @Test void testFolderLevelProviderResolution(JenkinsRule jenkins) throws Exception { ErrorExplainer errorExplainer = new ErrorExplainer(); diff --git a/src/test/java/io/jenkins/plugins/explain_error/GlobalConfigurationImplTest.java b/src/test/java/io/jenkins/plugins/explain_error/GlobalConfigurationImplTest.java index 13be1471..7803b94c 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/GlobalConfigurationImplTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/GlobalConfigurationImplTest.java @@ -48,6 +48,9 @@ void testGetSingletonInstance() { @Test void testDefaultValues() { assertTrue(config.isEnableExplanation()); + assertTrue(config.isEnableLogSanitization()); + assertFalse(config.isEnablePayloadPreview()); + assertTrue(config.isAuditSentPayload()); } @Test @@ -191,6 +194,22 @@ void testEnableExplanationSetterAndGetter() { assertTrue(config.isEnableExplanation()); } + @Test + void testPayloadProtectionConfiguration() { + config.setEnableLogSanitization(false); + config.setEnablePayloadPreview(true); + config.setAuditSentPayload(false); + config.setPayloadAllowRegex("ERROR|FAILED"); + config.setPayloadDenyRegex("customer-[0-9]+"); + + assertFalse(config.isEnableLogSanitization()); + assertTrue(config.isEnablePayloadPreview()); + assertFalse(config.isAuditSentPayload()); + assertEquals("ERROR|FAILED", config.getPayloadAllowRegex()); + assertEquals("customer-[0-9]+", config.getPayloadDenyRegex()); + assertFalse(config.getLogSanitizerPolicy().enabled()); + } + @Test void testConfigurationPersistence() { // Set some values diff --git a/src/test/java/io/jenkins/plugins/explain_error/LogSanitizerTest.java b/src/test/java/io/jenkins/plugins/explain_error/LogSanitizerTest.java new file mode 100644 index 00000000..bf5bbec2 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/LogSanitizerTest.java @@ -0,0 +1,84 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.regex.PatternSyntaxException; +import org.junit.jupiter.api.Test; + +class LogSanitizerTest { + + private final LogSanitizer sanitizer = new LogSanitizer(); + + @Test + void sanitize_redactsCommonSecretsAndInternalDetails() { + String payload = """ + ERROR password=super-secret + Authorization: Bearer abc.def.ghi + clone https://git.internal.example.com/team/private-repo.git + workspace /Users/alice/work/private-job/target/report.txt + contact customer@example.com + """; + + LogSanitizer.SanitizedPayload sanitized = sanitizer.sanitize(payload, LogSanitizer.Policy.enabled("", "")); + + assertFalse(sanitized.text().contains("super-secret")); + assertFalse(sanitized.text().contains("abc.def.ghi")); + assertFalse(sanitized.text().contains("git.internal.example.com")); + assertFalse(sanitized.text().contains("/Users/alice")); + assertFalse(sanitized.text().contains("customer@example.com")); + assertTrue(sanitized.text().contains(LogSanitizer.REDACTION_TOKEN)); + assertEquals(5, sanitized.redactionCount()); + } + + @Test + void sanitize_appliesAllowPolicyBeforeRedaction() { + String payload = """ + INFO harmless line + ERROR token=abc123 + FAILED customer id 42 + """; + + LogSanitizer.SanitizedPayload sanitized = sanitizer.sanitize(payload, + LogSanitizer.Policy.enabled("ERROR|FAILED", "")); + + assertFalse(sanitized.text().contains("INFO harmless line")); + assertTrue(sanitized.text().contains("ERROR token=" + LogSanitizer.REDACTION_TOKEN)); + assertTrue(sanitized.text().contains("FAILED customer id 42")); + assertEquals(1, sanitized.droppedLineCount()); + } + + @Test + void sanitize_appliesDenyPolicyAsRedaction() { + LogSanitizer.SanitizedPayload sanitized = sanitizer.sanitize("customer-id=acme-123", + LogSanitizer.Policy.enabled("", "acme-\\d+")); + + assertEquals("customer-id=" + LogSanitizer.REDACTION_TOKEN, sanitized.text()); + assertEquals(1, sanitized.redactionCount()); + } + + @Test + void sanitize_customDenyPolicyRedactsWholeMatchWhenRegexHasGroups() { + LogSanitizer.SanitizedPayload sanitized = sanitizer.sanitize("tenant=acme-123", + LogSanitizer.Policy.enabled("", "(acme)-(\\d+)")); + + assertEquals("tenant=" + LogSanitizer.REDACTION_TOKEN, sanitized.text()); + } + + @Test + void sanitize_canBeDisabled() { + String payload = "password=super-secret"; + + LogSanitizer.SanitizedPayload sanitized = sanitizer.sanitize(payload, LogSanitizer.Policy.disabled()); + + assertEquals(payload, sanitized.text()); + assertEquals(0, sanitized.redactionCount()); + } + + @Test + void validateRegex_rejectsInvalidRegex() { + assertThrows(PatternSyntaxException.class, () -> LogSanitizer.validateRegex("[")); + } +}