PII Redactor — Java source
Paste text and automatically detect and mask personal data — emails, phone numbers, IP addresses, SSNs, credit card numbers, and dates.
This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure PII detection & redaction logic — no UI, deterministic.
//
// Language: Java (17+, standard library only)
// Ported from src/lib/pii-redactor.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Regex-based detection for seven personal-data types; every regex candidate
// passes a structural validator (octet ranges, Luhn checksum, month/day
// bounds, E.164 digit count) to keep false positives low. Overlapping
// candidates resolve by type priority — unambiguous types (email, Luhn-passing
// card numbers, SSNs, IPs, dates) claim their span before the fuzzy phone
// pattern. Never throws.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class PiiRedactor {
/** The seven PII types the detector knows. */
public enum PiiType { EMAIL, PHONE, IPV4, IPV6, SSN, CREDIT_CARD, DATE }
/** One detected personal-data item: where it is and what it was. */
public record PiiMatch(PiiType type, int start, int end, String original) {
}
// Overlap resolution: when two candidates cover the same span, the more
// specific type wins. Phone is deliberately last — a date, SSN, IP, or card
// number can all masquerade as one.
private static int priority(PiiType type) {
return switch (type) {
case EMAIL -> 0;
case CREDIT_CARD -> 1;
case SSN -> 2;
case IPV6 -> 3;
case IPV4 -> 4;
case DATE -> 5;
case PHONE -> 6;
};
}
private PiiRedactor() {
}
/**
* Luhn checksum. {@code digits} must be a non-empty string of 0-9 (any
* separators make it invalid — strip them first). Returns false otherwise.
*/
public static boolean isValidLuhn(String digits) {
if (!digits.matches("\\d+")) {
return false;
}
int sum = 0;
boolean doubleIt = false;
for (int i = digits.length() - 1; i >= 0; i--) {
int d = digits.charAt(i) - 48;
if (doubleIt) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubleIt = !doubleIt;
}
return sum % 10 == 0;
}
// --- Per-type structural validators (regex candidates pass through these) ---
/** Octets 0-255 each; the regex already bounds the shape to a dotted quad. */
private static boolean isValidIpv4(String candidate) {
for (String o : candidate.split("\\.")) {
if (Integer.parseInt(o) > 255) {
return false;
}
}
return true;
}
private static final Pattern IPV6_GROUP = Pattern.compile("[A-Fa-f0-9]{1,4}");
/** Full 8-group form, or a compressed `::` form expanding to exactly 8. */
private static boolean isValidIpv6(String candidate) {
// Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
if (!candidate.matches(".*[A-Fa-f0-9].*")) {
return false;
}
String[] groups = candidate.split(":", -1);
boolean hasEmpty = false;
for (String g : groups) {
if (g.isEmpty()) {
hasEmpty = true;
break;
}
}
if (hasEmpty) {
// Compressed: at most one "::", its sides together hold < 8 groups.
String[] parts = candidate.split("::", -1);
if (parts.length > 2) {
return false;
}
List<String> side = new ArrayList<>();
for (String p : parts) {
if (!p.isEmpty()) {
for (String g : p.split(":", -1)) {
side.add(g);
}
}
}
if (side.size() > 7) {
return false;
}
for (String g : side) {
if (!IPV6_GROUP.matcher(g).matches()) {
return false;
}
}
return true;
}
if (groups.length != 8) {
return false;
}
for (String g : groups) {
if (!IPV6_GROUP.matcher(g).matches()) {
return false;
}
}
return true;
}
private static final Pattern DATE_SHAPE = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
/** ISO calendar plausibility: month 01-12, day 01-31. */
private static boolean isValidDate(String candidate) {
Matcher m = DATE_SHAPE.matcher(candidate);
if (!m.matches()) {
return false;
}
int month = Integer.parseInt(m.group(2));
int day = Integer.parseInt(m.group(3));
return month >= 1 && month <= 12 && day >= 1 && day <= 31;
}
/** E.164 digit budget (7-15) and structural guards for the fuzzy phone shape. */
private static boolean isValidPhone(String candidate) {
String digits = candidate.replaceAll("\\D", "");
if (digits.length() < 7 || digits.length() > 15) {
return false;
}
// A dotted quad is IP-shaped: if it were a valid IP it was already claimed
// by the ipv4 detector; an invalid one (999.x) is likelier a version string.
if (candidate.matches("\\d{1,3}(\\.\\d{1,3}){3}")) {
return false;
}
// YYYY-MM-DD shaped (even an impossible date) is never a phone number.
if (candidate.matches("\\d{4}-\\d{2}-\\d{2}")) {
return false;
}
return true;
}
/** 13-19 digits with optional space/dash grouping, plus a Luhn checksum. */
private static boolean isValidCard(String candidate) {
String digits = candidate.replaceAll("\\D", "");
return digits.length() >= 13 && digits.length() <= 19 && isValidLuhn(digits);
}
// --- Detectors: a global candidate regex + an optional structural validator ---
private interface Detector {
PiiType type();
Pattern pattern();
default boolean validate(String candidate) {
return true;
}
}
private record SimpleDetector(PiiType type, Pattern pattern) implements Detector {
}
private static final List<Detector> DETECTORS = List.of(
// RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
new SimpleDetector(PiiType.EMAIL,
Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}")),
// A maximal run of 12+ digits with single spaces/dashes as separators;
// isValidCard then enforces 13-19 digits + Luhn on the whole run.
new ValidatedDetector(PiiType.CREDIT_CARD,
Pattern.compile("\\d(?:[ -]?\\d){11,}"), PiiRedactor::isValidCard),
new SimpleDetector(PiiType.SSN, Pattern.compile("\\b\\d{3}-\\d{2}-\\d{4}\\b")),
// Hex groups joined by colons (>=2 colons); isValidIpv6 rejects prose
// like "10:30:45" (only 3 groups, no "::").
new ValidatedDetector(PiiType.IPV6,
Pattern.compile("(?<![:\\w])[A-Fa-f0-9]{0,4}(?::[A-Fa-f0-9]{0,4}){1,7}(?![:\\w])"),
PiiRedactor::isValidIpv6),
// Dotted quad; guards keep it out of versions ("v1.2.3.4") and longer
// quintets ("1.2.3.4.5") while allowing sentence-final periods.
new ValidatedDetector(PiiType.IPV4,
Pattern.compile("(?<![\\w.])(?:\\d{1,3}\\.){3}\\d{1,3}(?!\\.?\\d)(?!\\w)"),
PiiRedactor::isValidIpv4),
new ValidatedDetector(PiiType.DATE,
Pattern.compile("(?<!\\d)\\d{4}-\\d{2}-\\d{2}(?!\\d)"), PiiRedactor::isValidDate),
// Optional +country, optional (area), then 1-4 groups of 2-4 digits
// separated by spaces, dashes, or dots. Fuzziest pattern — lowest priority.
new ValidatedDetector(PiiType.PHONE,
Pattern.compile("(?<![\\d(])(?:\\+\\d{1,3}[ .-]?)?(?:\\(\\d{1,4}\\)|\\d{1,4})"
+ "(?:[ .-]?\\d{2,4}){1,4}(?!\\d)"),
PiiRedactor::isValidPhone));
private record ValidatedDetector(PiiType type, Pattern pattern,
java.util.function.Predicate<String> validator) implements Detector {
@Override
public boolean validate(String candidate) {
return validator.test(candidate);
}
}
/**
* Detect personal data in {@code text}. Pass {@code types} to scan for a
* subset (the per-type toggles); pass null to scan for everything. Returns
* matches in document order, non-overlapping, with exact start/end indices.
*/
public static List<PiiMatch> detectPii(String text, Set<PiiType> types) {
String source = text == null ? "" : text;
Set<PiiType> active = types == null ? null : EnumSet.copyOf(types);
List<PiiMatch> candidates = new ArrayList<>();
for (Detector det : DETECTORS) {
if (active != null && !active.contains(det.type())) {
continue;
}
Matcher m = det.pattern().matcher(source);
while (m.find()) {
if (m.group().isEmpty()) {
break; // zero-length safety; none of the patterns can
}
if (det.validate(m.group())) {
candidates.add(new PiiMatch(det.type(), m.start(), m.end(), m.group()));
}
}
}
// Highest-priority (lowest number) candidates claim their span first.
candidates.sort(Comparator.<PiiMatch>comparingInt(c -> priority(c.type()))
.thenComparingInt(PiiMatch::start));
List<PiiMatch> kept = new ArrayList<>();
for (PiiMatch c : candidates) {
boolean overlaps = false;
for (PiiMatch k : kept) {
if (c.start() < k.end() && k.start() < c.end()) {
overlaps = true;
break;
}
}
if (!overlaps) {
kept.add(c);
}
}
kept.sort(Comparator.comparingInt(PiiMatch::start));
return kept;
}
/** Redaction options: replacement mask + optional type subset. */
public record RedactOptions(String mask, Set<PiiType> types) {
}
/**
* Redact personal data from {@code text}, replacing every detected span with
* {@code mask} (default "[REDACTED]"). Accepts the same types subset as
* detectPii (null = all types).
*/
public static String redactPii(String text, RedactOptions options) {
String source = text == null ? "" : text;
String mask = options == null || options.mask() == null ? "[REDACTED]" : options.mask();
List<PiiMatch> matches = detectPii(source, options == null ? null : options.types());
StringBuilder out = new StringBuilder(source);
// Replace right-to-left so earlier indices stay valid.
for (int i = matches.size() - 1; i >= 0; i--) {
PiiMatch m = matches.get(i);
out.replace(m.start(), m.end(), mask);
}
return out.toString();
}
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →