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}