Finding.java

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

package dev.nichar.facepalm.engine;

import dev.nichar.facepalm.config.ScoringConfig;
import jakarta.annotation.Nonnull;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * Represents a discovered secret and its associated threat metadata.
 * Tracks risk, confidence, and scoring history throughout the evaluation pipeline.
 */
@Data
@Builder
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Finding {

    private final String patternName;

    /**
     * Unique hash for file-level deduplication.
     */
    @EqualsAndHashCode.Include
    private final String deduplicationHash;

    private final String secretValue;

    private final int lineNumber;

    private final FileContext context;

    private final String contextSnippet;

    private int riskScore;

    private int confidenceScore;

    /**
     * Historical log of scoring adjustments.
     */
    @Builder.Default
    private final List<String> scoreHistory = new ArrayList<>();

    /**
     * Updates threat scores and records the rule responsible for the adjustment.
     */
    public void log(@Nonnull final String rule, final int rDelta, final int cDelta) {
        riskScore = Math.max(0, Math.min(100, riskScore + rDelta));
        confidenceScore = Math.max(0, Math.min(100, confidenceScore + cDelta));
        scoreHistory.add(String.format(Locale.ROOT, "%s (%+d/%+d)", rule, rDelta, cDelta));
    }

    /**
     * Resolves the finding's severity based on configured threat thresholds.
     */
    @Nonnull
    public Severity getSeverity(@Nonnull final ScoringConfig config) {
        final double score = getNumericScore(config);
        if (score >= config.getErrorThreshold()) {
            return Severity.ERROR;
        }
        if (score >= config.getWarningThreshold()) {
            return Severity.WARNING;
        }
        return Severity.INFO;
    }

    /**
     * Calculates the composite threat score using the active scoring strategy.
     */
    public double getNumericScore(@Nonnull final ScoringConfig config) {
        return config.getStrategy().calculate(riskScore, confidenceScore);
    }

    /**
     * Returns a masked version of the secret for safe logging and reporting.
     * At most a quarter of the secret is revealed (up to 4 characters at each end); secrets shorter than 8
     * characters are fully masked.
     */
    @Nonnull
    public String getMaskedSecret() {
        final int reveal = Math.min(4, secretValue.length() / 8);
        return reveal == 0 ? "****"
            : secretValue.substring(0, reveal) + "..." + secretValue.substring(secretValue.length() - reveal);
    }

    /**
     * Returns the context snippet with every occurrence of the secret replaced by its masked form.
     * The raw snippet must never leave the process (logs, JSON, HTML, SARIF).
     */
    @Nonnull
    public String getMaskedSnippet() {
        if (contextSnippet == null) {
            return "";
        }
        return secretValue.isEmpty() ? contextSnippet : contextSnippet.replace(secretValue, getMaskedSecret());
    }
}