Skip to content

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 →