CredentialCsvEmitter.java

package org.egothor.methodatlas.emit;

import java.io.PrintWriter;
import java.util.List;
import java.util.Locale;

/**
 * Writes credential findings as a dedicated CSV document, separate from the
 * per-method scan CSV because the columns differ.
 *
 * <p>Schema version 1. Columns: {@code file, fqcn, method, begin_line,
 * begin_column, end_line, rule_id, category, detector_id, snippet_masked,
 * credibility_score, endpoint, rationale}. Renaming/reordering columns is a
 * breaking change — bump {@link #SCHEMA_VERSION} and update the docs.</p>
 *
 * @since 4.1.0
 */
public final class CredentialCsvEmitter {

    /** CSV schema version for the secrets report. */
    public static final int SCHEMA_VERSION = 1;

    private static final String HEADER = "file,fqcn,method,begin_line,begin_column,end_line,"
            + "rule_id,category,detector_id,snippet_masked,credibility_score,endpoint,rationale";

    /** Estimated row length for the StringBuilder capacity hint. */
    private static final int ROW_HINT = 192;

    private final boolean showValues;

    /**
     * Creates an emitter.
     *
     * @param showValues when {@code true}, emit raw values instead of masked snippets
     */
    public CredentialCsvEmitter(final boolean showValues) {
        this.showValues = showValues;
    }

    /**
     * Writes the header followed by one row per finding.
     *
     * @param out      destination; never {@code null}
     * @param findings findings to write; never {@code null}
     */
    public void flush(final PrintWriter out, final List<CredentialFinding> findings) {
        out.println(HEADER);
        for (CredentialFinding f : findings) {
            out.println(toRow(f));
        }
    }

    private String toRow(final CredentialFinding f) {
        final String snippet = showValues ? f.candidate().matchedValue()
                : CredentialMasker.mask(f.candidate().matchedValue());
        final String score = f.credibilityScore() == null ? ""
                : String.format(Locale.ROOT, "%.2f", f.credibilityScore());
        final String file = f.filePath().toString().replace('\\', '/');
        final StringBuilder sb = new StringBuilder(ROW_HINT);
        sb.append(esc(file)).append(',')
          .append(esc(nullToEmpty(f.fqcn()))).append(',')
          .append(esc(nullToEmpty(f.method()))).append(',')
          .append(f.candidate().beginLine()).append(',')
          .append(f.candidate().beginColumn()).append(',')
          .append(f.candidate().endLine()).append(',')
          .append(esc(f.candidate().ruleId())).append(',')
          .append(esc(f.candidate().category().name())).append(',')
          .append(esc(f.candidate().detectorId())).append(',')
          .append(esc(snippet)).append(',')
          .append(score).append(',')
          .append(esc(nullToEmpty(f.endpoint()))).append(',')
          .append(esc(nullToEmpty(f.rationale())));
        return sb.toString();
    }

    private static String nullToEmpty(final String s) {
        return s == null ? "" : s;
    }

    private static String esc(final String s) {
        if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0 && s.indexOf('\r') < 0) {
            return s;
        }
        return '"' + s.replace("\"", "\"\"") + '"';
    }
}