001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.util;
020
021import com.fasterxml.jackson.annotation.JsonInclude;
022import com.fasterxml.jackson.core.JsonProcessingException;
023import com.fasterxml.jackson.databind.ObjectMapper;
024import com.fasterxml.jackson.databind.module.SimpleModule;
025import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
026import org.jpos.core.Configurable;
027import org.jpos.core.Configuration;
028import org.jpos.core.ConfigurationException;
029import org.jpos.iso.ISOMsg;
030import org.jpos.iso.ISOUtil;
031import org.jpos.log.AuditLogEvent;
032import org.jpos.log.AuditLogEventRegistry;
033import org.jpos.log.evt.LogEvt;
034import org.jpos.log.evt.LogMessage;
035import org.jpos.log.evt.ThrowableAuditLogEvent;
036import org.jpos.log.render.ThrowableSerializer;
037
038import java.io.ByteArrayOutputStream;
039import java.io.PrintStream;
040import java.net.InetAddress;
041import java.time.Duration;
042import java.util.*;
043
044/**
045 * JSONL (one JSON object per line) LogEventWriter with built-in PCI protection.
046 *
047 * <p>When serializing ISOMsg objects in LogEvent payloads, sensitive fields are
048 * masked or wiped inline — no upstream {@link ProtectedLogListener} required.</p>
049 *
050 * <p>Configuration properties (same convention as {@link ProtectedLogListener}):</p>
051 * <ul>
052 *   <li>{@code protect} — space-separated field numbers to mask via {@link ISOUtil#protect(String)} (default: {@code "2"})</li>
053 *   <li>{@code wipe} — space-separated field numbers to replace with [WIPED] (default: {@code "35 45 48 52 55"})</li>
054 * </ul>
055 *
056 * <p>Output is suitable for {@code jq}, Filebeat, and Elasticsearch ingestion.</p>
057 *
058 * @since 3.0.0
059 */
060public class JsonlLogWriter extends BaseLogEventWriter implements Configurable {
061    private static final String WIPED = "[WIPED]";
062    private static final Set<Integer> DEFAULT_SAFE_FIELDS = Set.of(
063        3, 4, 7, 11, 12, 13, 18, 22, 24, 25, 32, 37, 38, 39, 41, 42, 49, 90
064    );
065
066    private ObjectMapper mapper;
067    private String host;
068    private Set<Integer> protectFields = Set.of(2);
069    private Set<Integer> wipeFields = Set.of(35, 45, 48, 52, 55);
070
071    /** Default constructor. */
072    public JsonlLogWriter() {
073        initMapper();
074        try {
075            host = InetAddress.getLocalHost().getHostName();
076        } catch (Exception e) {
077            host = "unknown";
078        }
079    }
080
081    @Override
082    public void setConfiguration(Configuration cfg) throws ConfigurationException {
083        String protect = cfg.get("protect", null);
084        if (protect != null) {
085            protectFields = toIntSet(protect);
086        }
087        String wipe = cfg.get("wipe", null);
088        if (wipe != null) {
089            wipeFields = toIntSet(wipe);
090        }
091        initMapper();
092    }
093
094    @Override
095    public void write(LogEvent ev) {
096        if (p == null || ev == null)
097            return;
098        try {
099            List<AuditLogEvent> events;
100            synchronized (ev.getPayLoad()) {
101                events = ev.getPayLoad()
102                    .stream()
103                    .map(this::toAuditLogEvent)
104                    .toList();
105            }
106            long elapsed = Duration.between(ev.getCreatedAt(), ev.getDumpedAt()).toMillis();
107            LogEvt logEvt = new LogEvt(
108                ev.getDumpedAt(),
109                ev.getTag(),
110                elapsed == 0L ? null : elapsed,
111                buildTags(ev),
112                events
113            );
114            p.println(mapper.writeValueAsString(logEvt));
115            p.flush();
116        } catch (JsonProcessingException e) {
117            p.println("{\"error\":\"" + e.getMessage().replace("\"", "'") + "\"}");
118            p.flush();
119        }
120    }
121
122    private AuditLogEvent toAuditLogEvent(Object obj) {
123        return switch (obj) {
124            case AuditLogEvent ale -> ale;
125            case ISOMsg m -> new LogMessage(protectAndDump(m));
126            case Throwable t -> new ThrowableAuditLogEvent(t);
127            default -> new LogMessage(dump(obj));
128        };
129    }
130
131    private Map<String,String> buildTags(LogEvent ev) {
132        ev.getTraceId(); // ensure trace-id is generated
133        Map<String,String> tags = new LinkedHashMap<>();
134        String realm = ev.getRealm();
135        if (realm != null && !realm.isEmpty())
136            tags.put("realm", realm);
137        if (host != null)
138            tags.put("host", host);
139        tags.putAll(ev.getTags());
140        return tags;
141    }
142
143    private String protectAndDump(ISOMsg original) {
144        ISOMsg m = (ISOMsg) original.clone();
145        for (int field : protectFields) {
146            String v = m.getString(field);
147            if (v != null) {
148                m.set(field, ISOUtil.protect(v));
149            }
150        }
151        for (int field : wipeFields) {
152            if (m.hasField(field)) {
153                m.set(field, WIPED);
154            }
155        }
156        return dump(m);
157    }
158
159    private String dump(Object obj) {
160        if (obj instanceof Loggeable loggeable) {
161            ByteArrayOutputStream baos = new ByteArrayOutputStream();
162            PrintStream ps = new PrintStream(baos);
163            loggeable.dump(ps, "");
164            return baos.toString().trim();
165        }
166        return obj.toString();
167    }
168
169    private void initMapper() {
170        mapper = new ObjectMapper();
171        mapper.registerModule(new JavaTimeModule());
172        mapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
173        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
174        SimpleModule module = new SimpleModule();
175        module.addSerializer(Throwable.class, new ThrowableSerializer());
176        mapper.registerModule(module);
177        AuditLogEventRegistry.register(mapper);
178    }
179
180    private static Set<Integer> toIntSet(String spaceSeparated) {
181        if (spaceSeparated == null || spaceSeparated.isBlank())
182            return Set.of();
183        Set<Integer> result = new HashSet<>();
184        for (String token : spaceSeparated.trim().split("\\s+")) {
185            result.add(Integer.parseInt(token));
186        }
187        return Collections.unmodifiableSet(result);
188    }
189}