Reporter.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.report;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.nichar.facepalm.FacepalmConfig;
import dev.nichar.facepalm.engine.Finding;
import dev.nichar.facepalm.engine.ScanReport;
import dev.nichar.facepalm.engine.ScanStatistics;
import dev.nichar.facepalm.engine.Severity;
import freemarker.core.HTMLOutputFormat;
import freemarker.template.Configuration;
import freemarker.template.TemplateExceptionHandler;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.LongAdder;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.maven.plugin.logging.Log;
/**
* Handles the generation of security reports and console logging.
* Supports HTML and SARIF formats for integration with CI/CD and developer workflows.
*/
@Named
@Singleton
public class Reporter {
/**
* Console separator line, matching Maven's standard 72-character width.
*/
private static final String SEPARATOR = "-".repeat(72);
@Inject
private FacepalmConfig context;
private final Log log;
private final Configuration cfg;
@Inject
public Reporter(@Nullable final Log log) {
this.log = log != null ? log : new org.apache.maven.plugin.logging.SystemStreamLog();
cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setClassForTemplateLoading(Reporter.class, "/templates");
cfg.setDefaultEncoding("UTF-8");
// Escape every interpolation: file paths and snippets come from the scanned repository.
cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
cfg.setFallbackOnNullLoopVariable(false);
}
/**
* Executes the full reporting pipeline, including console logs and file generation.
*/
@SuppressWarnings("unused") // TODO: SARIF + HTML reporting.
public void performReporting(@Nonnull final List<Finding> findings,
@Nonnull final ScanStatistics stats,
@Nonnull final String rootPath,
@Nonnull final String version,
@Nonnull final Path outputPathBase) throws Exception {
final var report = buildReport(findings, stats, rootPath, version);
generateHtml(report, outputPathBase.resolve("facepalm-report.html"));
generateSarif(report, outputPathBase.resolve("facepalm-report.sarif").toFile());
}
/**
* Generates an HTML report using Freemarker templates.
*/
public void generateHtml(@Nonnull final ScanReport report, @Nonnull final Path outputPath) throws Exception {
final var temp = cfg.getTemplate("report.html.ftl");
final var outputFile = outputPath.toFile();
if (outputFile.getParentFile() != null) {
outputFile.getParentFile().mkdirs();
}
try (final var out = new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8)) {
temp.process(report, out);
}
if (context.getScoring().isShowDetails()) {
log.info("HTML Report generated at: " + outputPath.toAbsolutePath());
}
}
/**
* Generates a SARIF report for integration with standard security analysis tools.
*/
public void generateSarif(@Nonnull final ScanReport report, @Nonnull final File outputFile) throws Exception {
final var mapper = new ObjectMapper();
final var sarif = mapper.createObjectNode();
sarif.put("$schema", "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json");
sarif.put("version", "2.1.0");
final var runs = sarif.putArray("runs");
final var run = runs.addObject();
final var tool = run.putObject("tool");
final var driver = tool.putObject("driver");
driver.put("name", "Facepalm");
driver.put("version", report.getMetadata().getScannerVersion());
final var results = run.putArray("results");
for (final var leak : report.getLeaks()) {
for (final var occ : leak.getOccurrences()) {
final var result = results.addObject();
result.put("ruleId", leak.getPrimaryRuleId());
result.put("level", leak.getTotalRisk() > 80 ? "error" : "warning");
final var message = result.putObject("message");
message.put("text", "Secret detected: " + leak.getMaskedSecret());
final var locations = result.putArray("locations");
final var loc = locations.addObject();
final var phys = loc.putObject("physicalLocation");
phys.putObject("artifactLocation").put("uri", occ.getRelativePath().replace("\\", "/"));
phys.putObject("region").put("startLine", occ.getLineNumber());
}
}
mapper.writerWithDefaultPrettyPrinter().writeValue(outputFile, sarif);
if (context.getScoring().isShowDetails()) {
log.info("Sarif Report generated at: " + outputFile.toPath().toAbsolutePath());
}
}
/**
* Aggregates raw discovery findings into a structured report model.
*/
@Nonnull
public ScanReport buildReport(@Nonnull final List<Finding> findings,
@Nonnull final ScanStatistics stats,
@Nonnull final String rootPath,
@Nonnull final String version) {
final var scoringConfig = context.getScoring();
// Group findings by unique secret fingerprints for deduplicated reporting.
final var grouped = findings.stream()
.collect(Collectors.groupingBy(Finding::getDeduplicationHash));
final var ruleDict = new HashMap<String, ScanReport.RuleDefinition>();
final var leaks = grouped.entrySet().stream().map(entry -> {
final var deduplicationHash = entry.getKey();
final var occs = entry.getValue();
final var primary = occs.get(0);
final var maxRisk = occs.stream().mapToInt(Finding::getRiskScore).max().orElse(0);
final var maxConfidence = occs.stream().mapToInt(Finding::getConfidenceScore).max().orElse(0);
final var maxScore = occs.stream().mapToDouble(f -> f.getNumericScore(scoringConfig)).max().orElse(0.0);
final var combinedHistory = occs.stream()
.flatMap(f -> f.getScoreHistory().stream())
.distinct()
.collect(Collectors.toList());
// Initialize metadata for the primary detection pattern.
ruleDict.putIfAbsent(
primary.getPatternName(), ScanReport.RuleDefinition.builder()
.id(primary.getPatternName())
.name(primary.getPatternName())
.description("Automated detection for " + primary.getPatternName())
.remediation("Revoke the secret immediately and update configuration.")
.build());
return ScanReport.UniqueLeak.builder()
.primaryRuleId(primary.getPatternName())
.totalRisk(maxRisk)
.totalConfidence(maxConfidence)
.aggregateScore(maxScore)
.maskedSecret(primary.getMaskedSecret())
.hash(deduplicationHash)
.scoreHistory(combinedHistory)
.occurrences(occs.stream().map(f -> ScanReport.Occurrence.builder()
.relativePath(f.getContext().getPath().toString())
.absolutePath(f.getContext().getPath().toAbsolutePath().toString())
.lineNumber(f.getLineNumber())
.snippet(f.getMaskedSnippet())
.build()).collect(Collectors.toList()))
.build();
})
// Sort discoveries by descending threat score.
.sorted(Comparator.comparing(ScanReport.UniqueLeak::getAggregateScore).reversed())
.collect(Collectors.toList());
return ScanReport.builder()
.metadata(ScanReport.RunMetadata.builder()
.scannerVersion(version)
.timestamp(Instant.now())
.rootPath(rootPath)
.build())
.summary(ScanReport.ScanSummary.builder()
.totalLeaksFound(leaks.size())
.totalOccurrences(findings.size())
.filesScanned((int) stats.getFilesScanned().sum())
.criticalCount((int) leaks.stream().filter(l -> l.getAggregateScore() > 80).count())
.warningCount((int) leaks.stream().filter(l -> l.getAggregateScore() <= 80 && l
.getAggregateScore() > 40).count())
.build())
.ruleDictionary(ruleDict)
.leaks(leaks)
.build();
}
/**
* Logs discovery findings and scan statistics to the Maven console.
*/
public void printLogs(@Nonnull final ScanStatistics stats, @Nonnull final List<Finding> findings) {
final var scoringConfig = context.getScoring();
if (scoringConfig.isShowScoring()) {
log.info(SEPARATOR);
findings.stream()
.filter(f -> f.getSeverity(scoringConfig) != Severity.INFO)
.sorted(Comparator.comparing((Finding f) -> f.getNumericScore(scoringConfig)).reversed())
.forEach(f -> {
final var severity = f.getSeverity(scoringConfig);
final var message = String.format(
Locale.ROOT,
"[%s] Score: %.1f (R:%d/C:%d) - %s",
f.getPatternName(), f.getNumericScore(scoringConfig),
f.getRiskScore(), f.getConfidenceScore(),
f.getContext().getPath() + ":" + f.getLineNumber());
// Emit high-risk findings at the error level for visibility.
switch (severity) {
case ERROR -> log.error(message);
case WARNING, INFO -> log.warn(message);
}
});
}
final long info = findings.stream().filter(f -> f.getSeverity(scoringConfig) == Severity.INFO).count();
final long errors = findings.stream().filter(f -> f.getSeverity(scoringConfig) == Severity.ERROR).count();
final long warnings = findings.stream().filter(f -> f.getSeverity(scoringConfig) == Severity.WARNING).count();
// Emit summary statistics if verbose detail is enabled.
if (scoringConfig.isShowDetails()) {
log.info(SEPARATOR);
log.info("Files discovered: " + stats.getFilesDiscovered().sum());
log.info("Files excluded: " + stats.getExclusionBreakdown().values().stream().mapToLong(LongAdder::sum)
.sum());
if (log.isDebugEnabled()) {
stats.getExclusionBreakdown().forEach(
(key, value) -> log.debug(String.format(Locale.ROOT, " %s: %d", key.getDescription(), value
.sum())));
}
log.info("Files scanned: " + stats.getFilesScanned().sum());
log.info(SEPARATOR);
log.info("Total findings: " + findings.size());
log.info("Critical: " + errors);
log.info("Warnings: " + warnings);
}
log.info(SEPARATOR);
final String statusMessage;
if (errors > 0) {
statusMessage = "High-risk issues detected! Action required.";
} else if (warnings > 0) {
statusMessage = "Warnings detected. Review recommended.";
} else if (info > 0) {
statusMessage = "Informational findings detected.";
} else {
statusMessage = "No secrets or sensitive patterns detected. Your secrets are safe.";
}
// Resolve scan status based on meeting configured thresholds.
final String scanResult;
if (errors > 0) {
scanResult = "FAILURE";
} else if (warnings > 0) {
scanResult = "WARNINGS";
} else {
scanResult = "SUCCESS";
}
log.info("SCAN RESULT : " + scanResult);
log.info(SEPARATOR);
log.info(statusMessage);
}
}