FacepalmReportMojo.java

/*
 * SPDX-License-Identifier: Apache-2.0
 * Copyright 2026 Nikolas Charalambidis
 */

package dev.nichar.facepalm;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.nichar.facepalm.report.FindingReport;
import jakarta.annotation.Nonnull;
import java.io.File;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.reporting.AbstractMavenReport;
import org.apache.maven.reporting.MavenReportException;

/**
 * Maven Site reporting Mojo for integrated security scan results.
 * Visualizes the JSON findings produced during the 'scan' phase.
 */
@Mojo(name = "report", defaultPhase = LifecyclePhase.SITE, threadSafe = true)
public class FacepalmReportMojo extends AbstractMavenReport {

    /**
     * Build directory where the scan goal writes {@code facepalm-findings.json}.
     */
    @Parameter(defaultValue = "${project.build.directory}", readonly = true)
    private File buildDirectory;

    /**
     * Unique identifier for the report in Maven Site navigation.
     */
    @Override
    public String getOutputName() {
        return "facepalm-report";
    }

    /**
     * Localized report name for the Site index.
     */
    @Override
    public String getName(@Nonnull final Locale locale) {
        return "Facepalm Report";
    }

    /**
     * Localized report description.
     */
    @Override
    public String getDescription(@Nonnull final Locale locale) {
        return "Scan results for leaked secrets.";
    }

    /**
     * Generates the Maven Site report by deserializing scan findings.
     * Translates raw JSON data into structured Doxia Sink elements.
     */
    @Override
    protected void executeReport(@Nonnull final Locale locale) throws MavenReportException {
        getLog().debug("Facepalm report generation output directory: " + outputDirectory.getAbsolutePath());

        // The scan goal writes the findings into the build directory, independent of the reporting directory.
        final var resultsFile = new File(buildDirectory, "facepalm-findings.json");

        if (!resultsFile.exists()) {
            getLog().warn("Facepalm results not found at " + resultsFile.getAbsolutePath() +
                ". Please run 'mvn verify' (or 'mvn facepalm:scan') before generating the site report.");
            renderEmptyReport();
            return;
        }

        final List<FindingReport> findings;
        try {
            final var mapper = new ObjectMapper();
            // Deserialize findings from the machine-readable JSON format.
            findings = mapper.readValue(
                resultsFile,
                mapper.getTypeFactory().constructCollectionType(List.class, FindingReport.class));
        } catch (Exception e) {
            throw new MavenReportException(
                "Failed to read " + resultsFile.getAbsolutePath() + ". Is the file corrupted?", e);
        }

        // Generate the HTML content using Maven's Doxia Sink API.
        renderReport(findings);
    }

    /**
     * Renders findings into structured HTML via the Doxia Sink API.
     */
    private void renderReport(@Nonnull final List<FindingReport> findings) {
        final var sink = getSink();

        // Apply descending sort by threat score.
        findings.sort(Comparator.comparing(FindingReport::getFinalScore, Comparator.reverseOrder()));

        // Document Head Section
        sink.head();
        sink.title();
        sink.text("Facepalm Security Report");
        sink.title_();
        sink.head_();

        sink.body();

        // Page Header
        sink.section1();
        sink.sectionTitle1();
        sink.text("Facepalm Security Scan Results");
        sink.sectionTitle1_();

        sink.paragraph();
        sink.text("The following potential secrets and security risks were identified during the verify phase.");
        sink.paragraph_();

        // High-level Summary Breakdown
        final long errors = findings.stream().filter(fr -> "ERROR".equals(fr.getFinalSeverity())).count();
        final long warnings = findings.stream().filter(fr -> "WARNING".equals(fr.getFinalSeverity())).count();

        sink.list();
        sink.listItem();
        sink.bold();
        sink.text("Critical Findings: ");
        sink.bold_();
        sink.text(String.valueOf(errors));
        sink.listItem_();
        sink.listItem();
        sink.bold();
        sink.text("Warnings: ");
        sink.bold_();
        sink.text(String.valueOf(warnings));
        sink.listItem_();
        sink.list_();

        sink.horizontalRule();

        // Render detailed findings as a tabular overview.
        if (findings.isEmpty()) {
            sink.paragraph();
            sink.italic();
            sink.text("No security findings detected. Your codebase looks clean!");
            sink.italic_();
            sink.paragraph_();
        } else {
            sink.table();

            // Render Table Header
            sink.tableRow();
            sink.tableHeaderCell();
            sink.text("Severity");
            sink.tableHeaderCell_();
            sink.tableHeaderCell();
            sink.text("Total Score");
            sink.tableHeaderCell_();
            sink.tableHeaderCell();
            sink.text("Risk Score");
            sink.tableHeaderCell_();
            sink.tableHeaderCell();
            sink.text("Confidence Score");
            sink.tableHeaderCell_();
            sink.tableHeaderCell();
            sink.text("Location");
            sink.tableHeaderCell_();
            sink.tableHeaderCell();
            sink.text("Pattern / Secret");
            sink.tableHeaderCell_();
            sink.tableRow_();

            // Populate Table Rows
            for (final var finding : findings) {
                sink.tableRow();

                // Severity Column with High-risk emphasis
                sink.tableCell();
                // Highlight high-risk findings with bold formatting.
                if ("ERROR".equals(finding.getFinalSeverity())) {
                    sink.bold();
                    sink.text("HIGH");
                    sink.bold_();
                } else {
                    sink.text(finding.getFinalSeverity());
                }
                sink.tableCell_();

                // Final Score Column
                sink.tableCell();
                sink.bold();
                sink.text(String.format(Locale.ROOT, "%.1f", finding.getFinalScore()));
                sink.bold_();
                sink.tableCell_();

                // Risk Score Column
                sink.tableCell();
                sink.text(String.valueOf(finding.getRiskScore()));
                sink.tableCell_();

                // Confidence Score Column
                sink.tableCell();
                sink.text(String.valueOf(finding.getConfidenceScore()));
                sink.tableCell_();

                // File Path and Line Number
                sink.tableCell();
                sink.italic();
                sink.text(finding.getFileAbsolutePath() + " (Line: " + finding.getLineNumber() + ")");
                sink.italic_();
                sink.tableCell_();

                // Masked Secret and Pattern Identification
                sink.tableCell();
                sink.text(finding.getPatternName() + ": ");
                sink.monospaced();
                sink.text(finding.getMaskedSecret());
                sink.monospaced_();
                sink.tableCell_();

                sink.tableRow_();
            }
            sink.table_();
        }

        sink.section1_();
        sink.body_();

        sink.flush();
        sink.close();
    }

    /**
     * Renders a placeholder message if scan data is missing.
     */
    private void renderEmptyReport() {
        final var sink = getSink();
        sink.head();
        sink.title();
        sink.text("Facepalm Scan Results");
        sink.title_();
        sink.head_();

        sink.body();
        sink.section1();
        sink.sectionTitle1();
        sink.text("Facepalm Security Report");
        sink.sectionTitle1_();

        sink.paragraph();
        sink.text(
            "No scan data available. You must run the facepalm:scan goal prior to site generation to see results here.");
        sink.paragraph_();

        sink.section1_();
        sink.body_();
        sink.flush();
        sink.close();
    }
}