RegexSecretExtractor.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.engine.extractor;
import dev.nichar.facepalm.FacepalmConfig;
import dev.nichar.facepalm.engine.FileContext;
import dev.nichar.facepalm.engine.Finding;
import dev.nichar.facepalm.pattern.SecretPattern;
import jakarta.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
/**
* Scans file content using regular expressions to identify potential secrets.
* Supports single-line matching and multi-line blocks like PEM certificates.
*/
@Named
@Singleton
public class RegexSecretExtractor implements SecretExtractor {
@Inject
private FacepalmConfig config;
@Nonnull
@Override
public List<Finding> extract(@Nonnull final FileContext context) {
final List<Finding> findings = new ArrayList<>();
final Set<String> localDedup = new HashSet<>();
final List<SecretPattern> activePatterns = config.getPatterns().getOverrides();
// Single-Line Scanning.
for (int i = 0; i < context.getLines().size(); i++) {
final var rawLine = context.getLines().get(i);
final var normalizedLine = rawLine.replace("\"", "").replace("'", "");
for (final var sp : activePatterns) {
if (sp.isMultiLine()) {
// Skip multi-line patterns to avoid redundant processing here.
continue;
}
final var m = sp.getPattern().matcher(normalizedLine);
while (m.find()) {
final var secretValue = secretValue(m);
if (!secretValue.isEmpty()) {
registerFinding(findings, localDedup, context, sp, secretValue, i + 1, rawLine);
}
}
}
}
// Multi-Line Block Scanning (e.g., PEM certificates).
// Built lazily on the first multi-line match, then shared by all patterns for this file.
LineIndex lineIndex = null;
for (final var sp : activePatterns) {
if (!sp.isMultiLine()) {
continue;
}
// Scan the entire file as a continuous block for multi-line patterns.
final var matcher = sp.getPattern().matcher(context.getFullContent());
while (matcher.find()) {
final var secretValue = matcher.group();
if (lineIndex == null) {
lineIndex = new LineIndex(context.getFullContent());
}
// Map character offset to its 1-based line number.
final var lineNum = lineIndex.lineNumberAt(matcher.start());
registerFinding(findings, localDedup, context, sp, secretValue, lineNum, "[MULTI-LINE BLOCK]");
}
}
return findings;
}
/**
* Returns capture group 1 if the pattern defines it and it participated in the match; otherwise the full match.
* An optional group such as {@code (foo)?bar} can match without group 1, which must not yield a {@code null}
* secret.
*/
@Nonnull
static String secretValue(@Nonnull final Matcher matcher) {
final var group = matcher.groupCount() >= 1 ? matcher.group(1) : null;
return group != null ? group : matcher.group();
}
/**
* Initializes and registers a discovery finding if it passes local deduplication.
*/
private void registerFinding(@Nonnull final List<Finding> findings,
@Nonnull final Set<String> dedup,
@Nonnull final FileContext ctx,
@Nonnull final SecretPattern sp,
@Nonnull final String value,
final int lineNum,
@Nonnull final String snippet) {
// Use pattern name and secret value for file-level deduplication.
final var hash = hashString(sp.getName() + value);
final var existing = findings.stream().filter(f -> f.getDeduplicationHash().equals(hash)).findFirst();
if (existing.isPresent()) {
// One finding per file for the same secret value to reduce noise.
return;
}
if (dedup.add(hash)) {
final var f = Finding.builder()
.patternName(sp.getName())
.deduplicationHash(hash)
.secretValue(value)
.lineNumber(lineNum)
.context(ctx)
.contextSnippet(snippet.trim())
.riskScore(sp.getBaseRisk())
.confidenceScore(sp.getBaseConfidence())
.build();
f.log("Base Pattern Match", 0, 0);
findings.add(f);
}
}
/**
* Generates a unique SHA-256 fingerprint for a discovery finding.
*/
private String hashString(@Nonnull final String input) {
try {
final var messageDigest = MessageDigest.getInstance("SHA-256");
final var hash = messageDigest.digest(input.getBytes(StandardCharsets.UTF_8));
final var hexString = new StringBuilder();
for (final var b : hash) {
hexString.append(String.format(Locale.ROOT, "%02x", b));
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
return String.valueOf(input.hashCode());
}
}
}