NagReportReader.java

package com.cloudforgeci.api.core.rules;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/**
 * Reads cdk-nag's own {@code <PackName>-<StackName>-NagReport.json} files back off disk after
 * {@code app.synth()} completes, implementing {@code ComplianceMode.ENFORCE}'s "blocks CDK
 * synthesis" behavior. {@code app.synth()} itself never throws for a cdk-nag
 * violation regardless of severity, so a caller has to inspect findings explicitly; this is
 * that inspection point.
 *
 * <p>Deliberately file-based, not an {@code INagLogger} callback. Every {@code NagPack} in {@link
 * SecurityRules#mapFrameworkToNagPack} already sets {@code reports(true)}, which enables cdk-nag's
 * own built-in report logger (writing these exact files) with no extra registration needed.
 * Registering an <em>additional</em> {@code INagLogger} alongside that built-in one triggers a
 * jsii/cdk-nag runtime bug (a {@link StackOverflowError} from reentrant kernel calls, independent
 * of what the extra logger's callbacks do), so reading the generated report after synthesis has
 * already finished is both simpler and the only path that avoids it.</p>
 */
public final class NagReportReader {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    private NagReportReader() {
    }

    /** One row from a {@code NagReport}'s {@code lines[]} array. */
    public record ComplianceFinding(String ruleId, String level, String resourceId, String message) {
        /** One bullet-list line, e.g. {@code "AwsSolutions-S1 (LogBucket): server access logging
         *  is not enabled"} -- shared by the blocking exception's own message and the advisory
         *  stack Output, so both read identically. */
        public String describe() {
            String subject = resourceId == null || resourceId.isBlank() ? ruleId : ruleId + " (" + resourceId + ")";
            return subject + ": " + message;
        }
    }

    /**
     * Every {@code Non-Compliant}, {@code Error}-level line across all {@code *-NagReport.json}
     * files cdk-nag wrote for {@code stackName} into {@code assemblyDirectory} -- one file per
     * applied framework/pack, hence scanning the whole directory rather than guessing a single
     * exact filename. Compliant, suppressed, and warning-level lines are intentionally excluded --
     * only what {@code ComplianceMode.ENFORCE} actually needs to block on.
     *
     * <p>Best-effort: a missing or unparseable report file is treated as "no findings from that
     * file" rather than failing synthesis over a reporting problem — the same fail-soft posture
     * the rest of this codebase's optional/best-effort reads already take.</p>
     */
    public static List<ComplianceFinding> readErrors(Path assemblyDirectory, String stackName) {
        return readReports(assemblyDirectory, stackName, true);
    }

    /**
     * Every line across all {@code *-NagReport.json} files cdk-nag wrote for {@code stackName},
     * regardless of compliance/level -- compliant, suppressed, and warning-level lines included.
     * Unlike {@link #readErrors} (which exists to answer "must synthesis be blocked"), this exists
     * to answer "what does a full compliance report for this stack look like", for a caller (an
     * on-demand advisory check, not a real deploy) that wants to show the user everything cdk-nag
     * has to say, not just what would fail an enforce-mode deploy.
     */
    public static List<ComplianceFinding> readAll(Path assemblyDirectory, String stackName) {
        return readReports(assemblyDirectory, stackName, false);
    }

    private static List<ComplianceFinding> readReports(Path assemblyDirectory, String stackName, boolean errorsOnly) {
        List<ComplianceFinding> findings = new ArrayList<>();
        String suffix = "-" + stackName + "-NagReport.json";
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(assemblyDirectory, "*" + suffix)) {
            for (Path reportFile : stream) {
                findings.addAll(readOneFile(reportFile, errorsOnly));
            }
        } catch (IOException ignored) {
            // Best-effort; see javadoc.
        }
        return findings;
    }

    private static List<ComplianceFinding> readOneFile(Path reportFile, boolean errorsOnly) {
        List<ComplianceFinding> findings = new ArrayList<>();
        try {
            JsonNode root = MAPPER.readTree(reportFile.toFile());
            JsonNode lines = root.path("lines");
            for (JsonNode line : lines) {
                String compliance = line.path("compliance").asText("");
                String level = line.path("ruleLevel").asText("");
                if (errorsOnly && (!isNonCompliant(compliance) || !"error".equals(level.toLowerCase(Locale.ROOT)))) {
                    continue;
                }
                findings.add(new ComplianceFinding(
                    line.path("ruleId").asText(null),
                    level.isBlank() ? compliance.toUpperCase(Locale.ROOT) : level.toUpperCase(Locale.ROOT),
                    line.path("resourceId").asText(null),
                    line.path("ruleInfo").asText(null)));
            }
        } catch (IOException ignored) {
            // Best-effort; see readErrors.
        }
        return findings;
    }

    /** cdk-nag's own {@code compliance} field is a human-readable string ({@code "Non-Compliant"}
     *  for a violation); matched case-insensitively and by substring so a minor wording
     *  change across cdk-nag versions degrades to "excluded" rather than a hard parse failure. */
    private static boolean isNonCompliant(String compliance) {
        return compliance.toLowerCase(Locale.ROOT).contains("non-compliant");
    }
}