ScannerEngine.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.engine;
import dev.nichar.facepalm.FacepalmConfig;
import dev.nichar.facepalm.engine.evaluator.FindingEvaluator;
import dev.nichar.facepalm.engine.extractor.SecretExtractor;
import dev.nichar.facepalm.engine.postprocessor.FileFindingsPostProcessor;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletionService;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.Executors;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import lombok.Getter;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.plugin.logging.SystemStreamLog;
/**
* Concurrent engine for file discovery and secret extraction.
* Orchestrates the parallel execution of the scanning pipeline across the project tree.
*/
@Named
@Singleton
public class ScannerEngine {
private final Log log;
@Inject
private FacepalmConfig context;
@Inject
private List<SecretExtractor> extractors;
@Inject
private List<FindingEvaluator> evaluators;
@Inject
private List<FileFindingsPostProcessor> fileProcessors;
@Getter
private final ScanStatistics stats = new ScanStatistics();
@Inject
public ScannerEngine(@Nullable final Log log) {
this.log = log != null ? log : new SystemStreamLog();
}
/**
* Traverses the project root and executes parallel scans on qualified files.
*
* @param root Project directory to scan.
* @return List of identified findings.
*/
@Nonnull
public List<Finding> scan(@Nonnull final Path root) throws InterruptedException, IOException {
// Capture configuration on the main thread for thread safety.
final var currentConfig = context;
final var engineConfig = currentConfig.getEngine();
final List<Callable<List<Finding>>> tasks = new ArrayList<>();
// Walk the filesystem and identify candidate files.
final var discoveredPaths = discover(root);
log.info("Discovered " + discoveredPaths.size() + " files...");
for (final var path : discoveredPaths) {
stats.recordDiscovery();
if (!shouldScan(path, stats)) {
continue;
}
stats.recordScan(path);
tasks.add(() -> processFile(root, path));
}
if (tasks.isEmpty()) {
log.info("No files qualified to scan.");
return Collections.emptyList();
}
log.info("Starting scan of " + tasks.size() + " files...");
// Initialize thread pool for parallel processing.
final var executor = Executors.newFixedThreadPool(engineConfig.getThreads());
try {
final CompletionService<List<Finding>> service = new ExecutorCompletionService<>(executor);
for (final var task : tasks) {
service.submit(task);
}
final List<Finding> allFindings = new ArrayList<>();
for (final Callable<List<Finding>> ignored : tasks) {
try {
allFindings.addAll(service.take().get());
} catch (ExecutionException e) {
// processFile handles its own failures; this only covers errors such as OutOfMemoryError.
log.warn("Error processing file: " + e.getCause(), e.getCause());
}
}
return allFindings;
} catch (InterruptedException e) {
// Restore the interrupt flag and abort: a partial result must not pass the build silently.
Thread.currentThread().interrupt();
throw e;
} finally {
// Shutdown the executor and await completion.
executor.shutdownNow();
}
}
/**
* Collects regular files below the root. Directories listed in {@code skipDirs} are not entered at all, and
* unreadable files or directories are logged and skipped instead of aborting the scan.
*
* <p>
* Exclusions match only directories inside the scan root, so folders above the project (e.g. a checkout under
* {@code /build/...}) and file names never exclude anything. Dotfiles such as {@code .env} are scanned; VCS and IDE
* directories are excluded through {@code skipDirs}.
*/
@Nonnull
private List<Path> discover(@Nonnull final Path root) throws IOException {
final var engineConfig = context.getEngine();
final var skipDirs = engineConfig.getSkipDirs();
final List<Path> files = new ArrayList<>();
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) {
if (isSkipDir(dir)) {
if (engineConfig.isShowSkipped()) {
log.debug("Skipping excluded directory: " + dir);
}
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) {
// Follows symbolic links to files, like the previous Files.walk + Files::isRegularFile.
if (attrs.isRegularFile() || Files.isRegularFile(file)) {
files.add(file);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(final Path file, final IOException exc) {
// An unreadable directory fails before preVisitDirectory is called; excluded ones stay silent.
if (isSkipDir(file)) {
return FileVisitResult.CONTINUE;
}
log.warn("Skipping unreadable path " + file + ": " + exc.getMessage());
stats.recordExclusion(ExclusionReason.IO_ERROR);
return FileVisitResult.CONTINUE;
}
private boolean isSkipDir(final Path dir) {
return !dir.equals(root) && dir.getFileName() != null
&& skipDirs.contains(dir.getFileName().toString());
}
});
return files;
}
/**
* Checks if a file qualifies for scanning based on size and type.
*/
private boolean shouldScan(@Nonnull final Path path, @Nonnull final ScanStatistics stats) {
final var engineConfig = context.getEngine();
final var fileName = path.getFileName().toString();
// Skip binary and non-text assets to reduce noise.
if (fileName.toLowerCase(Locale.ROOT).matches(engineConfig.getSkipBinaryRegex())) {
if (engineConfig.isShowSkipped()) {
log.debug("Skipping binary file: " + path);
}
stats.recordExclusion(ExclusionReason.BINARY_FILE);
return false;
}
try {
// Skip large files to prevent OOM during regex matching.
if (Files.size(path) > engineConfig.getMaxFileSizeBytes()) {
if (engineConfig.isShowSkipped()) {
log.debug("Skipping large file: " + path);
}
stats.recordExclusion(ExclusionReason.SIZE_EXCEEDED);
return false;
}
} catch (IOException e) {
log.error("Could not determine size for: " + path);
stats.recordExclusion(ExclusionReason.IO_ERROR);
return false;
}
return true;
}
/**
* Executes the extraction, evaluation, and post-processing pipeline for a single file.
*/
@Nonnull
private List<Finding> processFile(@Nonnull final Path root, @Nonnull final Path path) {
final var engineConfig = context.getEngine();
if (engineConfig.isShowProcessed()) {
log.debug("Processing file: " + path);
}
try {
// Load file into memory. Protected by size limits in shouldScan().
final var content = TextDecoder.decode(Files.readAllBytes(path));
if (content == null) {
if (engineConfig.isShowSkipped()) {
log.debug("Skipping " + path + " - binary content");
}
return Collections.emptyList();
}
// Initialize file context for the scanning pipeline.
final var context = new FileContext(path, root.relativize(path), content,
Arrays.asList(content.split("\\R")));
final List<Finding> fileFindings = new ArrayList<>();
// Execute regex-based secret extraction.
for (final var extractor : extractors) {
fileFindings.addAll(extractor.extract(context));
}
// Refine findings using heuristic evaluators.
for (final var finding : fileFindings) {
for (final var evaluator : evaluators) {
evaluator.evaluate(finding, context);
}
}
// Perform final file-level adjustments and deduplication.
for (final var processor : fileProcessors) {
processor.process(fileFindings, context);
}
return fileFindings;
} catch (RuntimeException e) {
// A failing extractor, evaluator or post-processor must not silently drop the file: report it.
log.warn("Failed to scan " + path + ": " + e, e);
stats.recordExclusion(ExclusionReason.PROCESSING_ERROR);
return Collections.emptyList();
} catch (IOException e) {
log.warn("Skipping unreadable file " + path + ": " + e.getMessage());
return Collections.emptyList();
}
}
}