diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileInclusionFilter.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileInclusionFilter.java new file mode 100644 index 0000000000..c938a3ddb7 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileInclusionFilter.java @@ -0,0 +1,76 @@ +package io.jenkins.plugins.analysis.core.filter; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import org.jenkinsci.Symbol; + +import java.io.Serial; +import java.io.Serializable; + +import hudson.Extension; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Descriptor; + +/** + * A filter that restricts static analysis issues to only those contained within a specific list of files. + * + *

+ * This is particularly useful for CI pipelines where you want to report issues only for files + * changed in a specific Git patch, avoiding the character limits associated with long + * regular expression strings. + *

+ * + * @author Your Name + */ +public class FileInclusionFilter extends AbstractDescribableImpl implements Serializable { + @Serial + private static final long serialVersionUID = 1643462711241633469L; + + /** + * The path to the text file containing the list of files to be included. + * Each line in this file should represent a relative or absolute path + * to a file that is allowed to have reported issues. + */ + private final String fileName; + + @Override + public Descriptor getDescriptor() { + return new DescriptorImpl(); + } + + /** + * Creates a new instance of {@link FileInclusionFilter}. + * + * @param fileName + * the path to the file containing the list of allowed file names (one per line). + * Note: If running on a distributed Jenkins setup, this path must be accessible + * on the controller or handled via FilePath. A blank or null value is treated as + * "no filter" and stored as null. + */ + public FileInclusionFilter(final String fileName) { + super(); + this.fileName = fileName != null && !fileName.isBlank() ? fileName : null; + } + + /** + * Returns the path to the file containing the inclusion list. + * + * @return the file name + */ + public String getFileName() { + return fileName; + } + + /** + * Descriptor for {@link FileInclusionFilter}. + */ + @Extension + @Symbol("fileInclusionFilter") + public static class DescriptorImpl extends Descriptor { + @Override + @NonNull + public String getDisplayName() { + return "Include only files listed in file"; + } + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileNameFilter.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileNameFilter.java new file mode 100644 index 0000000000..f92fb7f7b7 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FileNameFilter.java @@ -0,0 +1,60 @@ +package io.jenkins.plugins.analysis.core.filter; + +import java.util.Collection; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import edu.hm.hafner.analysis.Issue; + +/** + * A {@link Predicate} that filters {@link Issue} instances by checking their file names against + * a provided collection of allowed paths. + * + *

+ * This filter is designed to efficiently handle the intersection between the absolute paths + * often reported by static analysis tools and the relative paths typically generated by + * SCM tools (like {@code git diff}). It uses an "ends-with" matching strategy to bridge + * these differences. + *

+ * + * @author Your Name + */ +public class FileNameFilter implements Predicate { + /** The set of file paths that are permitted to remain in the report. */ + private final Set allowedFiles; + + /** + * Creates a new instance of {@link FileNameFilter}. + * + * @param fileList + * the collection of file paths to include in the filter. These are typically + * retrieved from a version control system's diff output. + */ + public FileNameFilter(final Collection fileList) { + this.allowedFiles = fileList.stream().collect(Collectors.toSet()); + } + + /** + * Evaluates this predicate on the given issue. + * + *

+ * An issue passes the filter if its file name ends with any of the strings in the + * allowed files set. This ensures that an issue at {@code /absolute/path/to/src/File.ts} + * matches an allowed entry of {@code src/File.ts}. + *

+ * + * @param issue + * the issue to test. + * @return {@code true} if the issue's file name is allowed, {@code false} otherwise. + */ + @Override + public boolean test(final Issue issue) { + String fileName = issue.getFileName(); + + // Match if the issue's path ends with any file in our diff list. + // Stream search is used here; for extremely large sets, a suffix-tree + // approach could be used to further optimize if necessary. + return allowedFiles.stream().anyMatch(fileName::endsWith); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FilterConfig.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FilterConfig.java new file mode 100644 index 0000000000..9935f987d0 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/filter/FilterConfig.java @@ -0,0 +1,80 @@ +package io.jenkins.plugins.analysis.core.filter; + +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.io.Serializable; +import java.util.List; +import java.util.logging.Logger; + +import hudson.FilePath; + +/** + * Bundles filter configuration for issue scanning. Groups regex-based filters and an optional + * file-based inclusion filter into a single parameter object. + * + * @param filters the list of regular expression filters to apply to issues + * @param filesFilter an optional path to a file that lists the files to include + */ +public record FilterConfig( + List filters, + String filesFilter) implements Serializable { + private static final Logger LOGGER = Logger.getLogger(FilterConfig.class.getName()); + + /** + * Reads the file inclusion list from the workspace and creates a {@link FileNameFilter}. + * + * @param workspace + * the workspace path used to resolve the filter file location + * @return a {@link FileNameFilter} if the filter file is configured and readable, + * {@code null} otherwise + */ + public FileNameFilter readFileNameFilter(final FilePath workspace) { + if (filesFilter == null || workspace == null || filesFilter.isBlank()) { + return null; + } + + // Security: Paths.get().normalize() resolves ".." and "." traversal components. + // CodeQL recognizes this as a taint sanitizer for path-injection, breaking the + // taint chain from the user-controlled filesFilter. After normalization, we verify + // the result is not absolute and does not start with ".." (which would indicate + // traversal outside the base). Finally, isDescendant() provides defense-in-depth. + Path normalized; + try { + normalized = Paths.get(filesFilter).normalize(); + } + catch (InvalidPathException e) { + LOGGER.warning(() -> String.format( + "Rejected invalid filter file path in plugin configuration: '%s'", filesFilter)); + return null; + } + + if (normalized.isAbsolute() || normalized.startsWith("..")) { + LOGGER.warning(() -> String.format( + "Rejected unsafe filter file path in plugin configuration: '%s'", filesFilter)); + return null; + } + + String safePath = normalized.toString(); + + try { + FilePath fileFilterPath = workspace.child(safePath); + if (!fileFilterPath.isDescendant(workspace.getRemote())) { + LOGGER.warning(() -> String.format( + "Blocked potential path traversal attempt in plugin configuration. Target path '%s' is outside of workspace '%s'", + filesFilter, workspace.getRemote() + )); + return null; + } + + String content = fileFilterPath.readToString(); + List lines = Arrays.asList(content.split("\\r?\\n")); + return new FileNameFilter(lines); + } + catch (IOException | InterruptedException e) { + return null; + } + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesRecorder.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesRecorder.java index 628e3f5705..2b9b24a04e 100644 --- a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesRecorder.java +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesRecorder.java @@ -45,7 +45,7 @@ import hudson.util.FormValidation; import hudson.util.ListBoxModel; import jenkins.model.Jenkins; - +import io.jenkins.plugins.analysis.core.filter.FilterConfig; import io.jenkins.plugins.analysis.core.filter.RegexpFilter; import io.jenkins.plugins.analysis.core.model.AnalysisResult; import io.jenkins.plugins.analysis.core.model.HealthReportBuilder; @@ -111,6 +111,7 @@ public class IssuesRecorder extends Recorder { private Severity minimumSeverity = Severity.WARNING_LOW; private List filters = new ArrayList<>(); + private String filesFilter; private boolean isEnabledForFailure; private boolean isAggregatingResults; @@ -689,6 +690,21 @@ public void setFilters(final List filters) { this.filters = new ArrayList<>(filters); } + public String getFilesFilter() { + return this.filesFilter; + } + + /** + * Sets the file path to read the files that should be included in the recording. + * + * @param filePath + * the path to the file listing the files to include + */ + @DataBoundSetter + public void setFilesFilter(final String filePath) { + this.filesFilter = filePath; + } + public void setChecksInfo(@CheckForNull final ChecksInfo checksInfo) { this.checksInfo = checksInfo; } @@ -825,7 +841,8 @@ private String getReportName(final Tool tool) { private AnnotatedReport scanWithTool(final Run run, final FilePath workspace, final TaskListener listener, final Tool tool) throws IOException, InterruptedException { - var issuesScanner = new IssuesScanner(tool, getFilters(), getSourceCodeCharset(), + var filterConfig = new FilterConfig(getFilters(), filesFilter); + var issuesScanner = new IssuesScanner(tool, filterConfig, getSourceCodeCharset(), workspace, getSourceCodePaths(), getSourceCodeRetention(), run, new FilePath(run.getRootDir()), listener, scm, isBlameDisabled ? BlameMode.DISABLED : BlameMode.ENABLED, diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesScanner.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesScanner.java index 383e9a4ac2..1e233acdad 100644 --- a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesScanner.java +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/IssuesScanner.java @@ -22,7 +22,6 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; @@ -33,7 +32,7 @@ import hudson.model.TaskListener; import hudson.remoting.VirtualChannel; import jenkins.MasterToSlaveFileCallable; - +import io.jenkins.plugins.analysis.core.filter.FilterConfig; import io.jenkins.plugins.analysis.core.filter.RegexpFilter; import io.jenkins.plugins.analysis.core.model.ReportLocations; import io.jenkins.plugins.analysis.core.model.ReportScanningTool; @@ -70,7 +69,7 @@ class IssuesScanner { private final FilePath jenkinsRootDir; private final Charset sourceCodeEncoding; private final Tool tool; - private final List filters; + private final FilterConfig filterConfig; private final TaskListener listener; private final String scm; private final BlameMode blameMode; @@ -88,13 +87,14 @@ enum PostProcessingMode { } @SuppressWarnings("checkstyle:ParameterNumber") - IssuesScanner(final Tool tool, final List filters, final Charset sourceCodeEncoding, + IssuesScanner(final Tool tool, final FilterConfig filterConfig, + final Charset sourceCodeEncoding, final FilePath workspace, final Set sourceDirectories, final SourceCodeRetention sourceCodeRetention, final Run run, final FilePath jenkinsRootDir, final TaskListener listener, final String scm, final BlameMode blameMode, final PostProcessingMode postProcessingMode, final boolean quiet, final String sourcePathPrefix, final String targetPathPrefix) { - this.filters = new ArrayList<>(filters); + this.filterConfig = filterConfig; this.sourceCodeEncoding = sourceCodeEncoding; this.tool = tool; this.workspace = workspace; @@ -146,7 +146,7 @@ private AnnotatedReport postProcessReport(final Report report) throws IOExceptio } else { report.logInfo("Skipping post processing"); - return new AnnotatedReport(tool.getActualId(), filter(report, filters)); + return new AnnotatedReport(tool.getActualId(), filter(report, filterConfig, workspace)); } } @@ -156,7 +156,7 @@ private ReportPostProcessor createPostProcessor(final Report report) { linesLookAhead = scanningTool.getLinesLookAhead(); } return new ReportPostProcessor(tool.getActualId(), report, sourceCodeEncoding.name(), - createBlamer(report), filters, getPermittedSourceDirectories(), sourceDirectories, + createBlamer(report), filterConfig, getPermittedSourceDirectories(), sourceDirectories, postProcessingMode, linesLookAhead, sourcePathPrefix, targetPathPrefix); } @@ -230,30 +230,42 @@ private String getComputerName() { return StringUtils.EMPTY; } - private static Report filter(final Report report, final List filters) { + private static Report filter(final Report report, final FilterConfig filterConfig, + final FilePath workspace) { int actualFilterSize = 0; var builder = new IssueFilterBuilder(); - for (RegexpFilter filter : filters) { + for (RegexpFilter filter : filterConfig.filters()) { if (StringUtils.isNotBlank(filter.getPattern())) { filter.apply(builder); actualFilterSize++; } } + var filtered = report.filter(builder.build()); + var result = filtered; + + var fileNameFilter = filterConfig.readFileNameFilter(workspace); + if (fileNameFilter != null) { + result = filtered.filter(fileNameFilter); + actualFilterSize++; + } + if (actualFilterSize > 0) { filtered.logInfo( "Applying %d filters on the set of %d issues (%d issues have been removed, %d issues will be published)", - filters.size(), report.size(), report.size() - filtered.size(), filtered.size()); + actualFilterSize, report.size(), report.size() - filtered.size(), result.size()); } else { - filtered.logInfo("No filter has been set, publishing all %d issues", filtered.size()); + filtered.logInfo("No filter has been set, publishing all %d issues", result.size()); } - return filtered; + return result; } /** - * Post processes the report on the build agent. Assigns absolute paths, package names, and module names and - * computes fingerprints for each issue. Finally, for each file the SCM blames are computed. + * Post processes the report on the build agent. Assigns absolute paths, package + * names, and module names and + * computes fingerprints for each issue. Finally, for each file the SCM blames + * are computed. */ @SuppressWarnings("checkstyle:ClassDataAbstractionCoupling") private static class ReportPostProcessor extends MasterToSlaveFileCallable { @@ -270,23 +282,25 @@ private static class ReportPostProcessor extends MasterToSlaveFileCallable requestedSourceDirectories; private final PostProcessingMode postProcessingMode; @SuppressWarnings("serial") - private final List filters; + private final FilterConfig filterConfig; private final int linesLookAhead; private final String sourcePathPrefix; private final String targetPathPrefix; @SuppressWarnings("checkstyle:ParameterNumber") ReportPostProcessor(final String id, final Report report, final String sourceCodeEncoding, - final Blamer blamer, final List filters, final Set permittedSourceDirectories, - final Set requestedSourceDirectories, final PostProcessingMode postProcessingMode, final int linesLookAhead, - final String sourcePathPrefix, final String targetPathPrefix) { + final Blamer blamer, final FilterConfig filterConfig, + final Set permittedSourceDirectories, + final Set requestedSourceDirectories, final PostProcessingMode postProcessingMode, + final int linesLookAhead, + final String sourcePathPrefix, final String targetPathPrefix) { super(); this.id = id; originalReport = report; this.sourceCodeEncoding = sourceCodeEncoding; this.blamer = blamer; - this.filters = filters; + this.filterConfig = filterConfig; this.permittedSourceDirectories = permittedSourceDirectories; this.requestedSourceDirectories = requestedSourceDirectories; this.postProcessingMode = postProcessingMode; @@ -306,7 +320,7 @@ public AnnotatedReport invoke(final File workspace, final VirtualChannel channel originalReport.logInfo(SKIPPING_POST_PROCESSING); } - Report filtered = filter(originalReport, filters); // the filters may depend on the resolved paths + Report filtered = filter(originalReport, filterConfig, new FilePath(workspace)); // the filters may depend on the resolved paths createFingerprints(filtered); diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/RecordIssuesStep.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/RecordIssuesStep.java index 814f776fe4..276b7cc4dd 100644 --- a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/RecordIssuesStep.java +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/RecordIssuesStep.java @@ -29,7 +29,7 @@ import hudson.model.Result; import hudson.model.Run; import hudson.model.TaskListener; - +import io.jenkins.plugins.analysis.core.filter.FileInclusionFilter; import io.jenkins.plugins.analysis.core.filter.RegexpFilter; import io.jenkins.plugins.analysis.core.model.AnalysisResult; import io.jenkins.plugins.analysis.core.model.HealthReportBuilder; @@ -83,6 +83,7 @@ public class RecordIssuesStep extends Step implements Serializable { @SuppressWarnings("serial") private List filters = new ArrayList<>(); + private FileInclusionFilter filesFilter; private boolean isEnabledForFailure; private boolean isAggregatingResults; @@ -638,6 +639,15 @@ public void setFilters(final List filters) { this.filters = new ArrayList<>(filters); } + public String getFilesFilter() { + return filesFilter != null ? filesFilter.getFileName() : null; + } + + @DataBoundSetter + public void setFilesFilter(final String filePath) { + this.filesFilter = new FileInclusionFilter(filePath); + } + @Override public StepExecution start(final StepContext context) { return new Execution(context, this); @@ -669,6 +679,7 @@ protected List run() throws IOException, InterruptedException { recorder.setUnhealthy(step.getUnhealthy()); recorder.setMinimumSeverity(step.getMinimumSeverity()); recorder.setFilters(step.getFilters()); + recorder.setFilesFilter(step.getFilesFilter()); recorder.setEnabledForFailure(step.getEnabledForFailure()); recorder.setAggregatingResults(step.getAggregatingResults()); recorder.setBlameDisabled(step.isSkipBlames()); diff --git a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/ScanForIssuesStep.java b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/ScanForIssuesStep.java index 0515791dbe..fe9c486576 100644 --- a/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/ScanForIssuesStep.java +++ b/plugin/src/main/java/io/jenkins/plugins/analysis/core/steps/ScanForIssuesStep.java @@ -24,7 +24,8 @@ import hudson.FilePath; import hudson.model.Run; import hudson.model.TaskListener; - +import io.jenkins.plugins.analysis.core.filter.FileInclusionFilter; +import io.jenkins.plugins.analysis.core.filter.FilterConfig; import io.jenkins.plugins.analysis.core.filter.RegexpFilter; import io.jenkins.plugins.analysis.core.model.Tool; import io.jenkins.plugins.analysis.core.steps.IssuesScanner.BlameMode; @@ -47,6 +48,7 @@ public class ScanForIssuesStep extends Step { private boolean quiet; private List filters = new ArrayList<>(); + private FileInclusionFilter filesFilter; private String scm = StringUtils.EMPTY; private String sourcePathPrefix = StringUtils.EMPTY; // @since 10.7.0 @@ -87,6 +89,15 @@ public void setFilters(final List filters) { this.filters = new ArrayList<>(filters); } + public String getFilesFilter() { + return filesFilter != null ? filesFilter.getFileName() : null; + } + + @DataBoundSetter + public void setFileFilters(final String filePath) { + this.filesFilter = new FileInclusionFilter(filePath); + } + /** * Sets whether logging output should be enabled. * @@ -274,7 +285,7 @@ static class Execution extends AnalysisExecution { private final boolean isBlameDisabled; private final boolean skipPostProcessing; @SuppressWarnings("serial") - private final List filters; + private final FilterConfig filterConfig; @SuppressWarnings("serial") private final Set sourceDirectories; private final String scm; @@ -297,7 +308,7 @@ static class Execution extends AnalysisExecution { tool = step.getTool(); sourceCodeEncoding = step.getSourceCodeEncoding(); isBlameDisabled = step.isSkipBlames(); - filters = step.getFilters(); + filterConfig = new FilterConfig(step.getFilters(), step.getFilesFilter()); sourceDirectories = step.getAllSourceDirectories(); sourceCodeRetention = step.getSourceCodeRetention(); scm = step.getScm(); @@ -312,7 +323,7 @@ protected AnnotatedReport run() throws IOException, InterruptedException, Illega var workspace = getWorkspace(); var listener = getTaskListener(); - var issuesScanner = new IssuesScanner(tool, filters, + var issuesScanner = new IssuesScanner(tool, filterConfig, getCharset(sourceCodeEncoding), workspace, sourceDirectories, sourceCodeRetention, getRun(), new FilePath(getRun().getRootDir()), listener, scm, isBlameDisabled ? BlameMode.DISABLED : BlameMode.ENABLED, diff --git a/plugin/src/main/resources/issues/scan-parameters.jelly b/plugin/src/main/resources/issues/scan-parameters.jelly index 44d6ed3c9b..6a6571ae3e 100644 --- a/plugin/src/main/resources/issues/scan-parameters.jelly +++ b/plugin/src/main/resources/issues/scan-parameters.jelly @@ -24,6 +24,10 @@ + + + + diff --git a/plugin/src/main/resources/issues/scan-parameters.properties b/plugin/src/main/resources/issues/scan-parameters.properties index 46ec1a03a6..d0530cb7af 100644 --- a/plugin/src/main/resources/issues/scan-parameters.properties +++ b/plugin/src/main/resources/issues/scan-parameters.properties @@ -6,6 +6,7 @@ description.sourceDirectories=Additional paths to the source code if not in the title.blameDisabled=Disable retrieval of blame information (author and commit) from SCM title.skipPostProcessing=Disable detection of missing package and module names title.filter=Issue Filters +title.filelist=Filename for list inclusion description.filter=Issues will be matched with all the specified filters. If no filter is \ defined, then all issues will be published. Filters with empty regular expression will be ignored. sourceCodeRetention.title=Source Code Retention Strategy @@ -15,3 +16,4 @@ description.sourcePathPrefix=Path prefix to be replaced in file paths (e.g., pat title.targetPathPrefix=Target Path Prefix description.targetPathPrefix=Path prefix to replace with (e.g., path in Jenkins workspace: /var/jenkins/workspace). \ Leave empty to disable path remapping. Both source and target prefixes must be provided for remapping to work. +description.filename=Specify the filter file to apply. The file should contains the file to be included in the filter.