如何基于java代理对大数据缓存组件返回的数据进行脱敏和阻断

  • 背景
  • 架构拓扑图
  • 实现方式对比
  • UDF方案
  • 优点:
  • 缺点:
  • 改写返回结果方案
  • 优点:
  • 缺点:
  • 说明
  • 实现
  • 默认处理方式
  • redis报文解析器
  • 代码解析
  • 测试方案
  • 前提条件
  • 测试脚本及命令
  • 最终效果
  • 思考
  • 温馨提示:


背景

上周刚把基于关系型数据库的拦截及脱敏的代码做了一些完善与修复,开源关系型数据库已经都做了,其他的数据库也不方便再公开了,但是问题来了,其原理是拦截客户端的请求修改请求发送给服务端的,如果说服务端是非关系型的大数据组件数据库不支持这样的复杂请求又该怎么办呢,那就只能拦截返回的结果进行修改了,这篇文章将尝试解析基于非关系型数据库的返回结果报文拦截脱敏,本文暂时以redis为例子,其他类型后续有时间再更新,这里可以先分析下两种实现方式的架构、原理及优缺点。

架构拓扑图

java将文件缓存到内存 java缓存数据_big data


图片来源于网络借鉴

因为我们的要求只是要把展示给用户的数据进行修改,并没有修改到数据本身,肯定是不能到数据库中去修改数据的,所以只能在数据库和客户端之间下功夫,一种方案是直接在在请求数据的时候用复杂的sql去截取字让数据库返回脱敏后的内容,另外一种是数据库返回完整的数据客户端自己对结果进行替换遮盖,如果没有代理服务在中间的话由客户端操作那么每一个客户端都需要做同样的事,而且服务端还是把明文数据给客户端了,存在一定的风险,目标就是不希望客户端看到明文。最好的结果就是对于客户端和服务端都是无感知的进行,客户端只需要正常发送请求,而服务端也只需要正常执行请求返回结果就可以了,因此解决方案就在代理服务器上模拟一个数据库服务端,对拦截到的数据进行处理,客户端去访问代理服务器,代理服务器去访问真实服务器,然后由代理服务器对数据进行处理,这个代理服务器也就是常用的tcp端口代理服务,前面已经讲过这个服务怎么实现,可用bio,nio,netty等方式实现都可以。

实现方式对比

UDF方案

优点:

1、传输安全,从数据库中出来的数据就已经是脱敏后的内容了,不存在泄露风险。
2、脱敏效率高、压力分散化,代理服务器只修改请求的sql修改的内容较少,代理服务器只管数据转发压力相对较轻,把压力都分散到了各个数据库上去,对代理服务器要求相对较低。

缺点:

1、兼容性稍微差点,必须要相应的数据库支持UDF功能才行。
2、对数据库服务器有一定的影响,如果替换的sql特别复杂效率低,在数据库中执行耗时影响其他业务系统使用。

改写返回结果方案

优点:

1、兼容性强,不需要管服务端的数据来源是什么,只要能返回数据就可以脱敏,支持任意数据库。
2、数据库不需要做任何计算,对数据库不会有什么负担。

缺点:

1、存在一定的安全隐患,数据从真实服务器到代理服务器之间传输的是真实数据,有被拦截的风向(但是两个服务器本来就是互相信任你的安全级别也不在这里)。
2、业务逻辑复杂,因为请求包和返回结果是不同服务器来的包,需要互通作为判断条件,所以需要把请求包存储在代理服务器上,且返回的数据包格式太多,兼容适配起来比较麻烦,返回的数据量也比较大,需要分包处理。
3、运行效率不高,因为所有的请求的数据都需要在代理服务器上进行解析,脱敏,封装并且计算,这样一来所有压力都在代理服务器上,而且需要处理的数据量也比较多,如果一条sql查询了十万条数据那么这十万条数据都需要在代理服务器上进行解析脱敏和计算,网络传输中的每一个数据包都是有大小限制的,数据量多了数据会在多个数据包中,这样就会让代理服务器解析比较麻烦,极端情况有可能需要把所有数据读取到本地才能解析,会有很大的性能影响,非必要场合不建议使用。

说明

由于第一种实现方式上一篇文章已经说过了,这里主要说讲解第二种方案,以redis为例进行解析,虽然java工程的redis可以直接用spring自带的序列化工具进行序列化,但是序列化之后就全是密文外部也看不懂了,且序列化内容比较占用空间,对于特殊的业务场景的还是有作用的。

实现

接下来将对数据库的报文进行解析:

默认处理方式

/**
 * @description: 默认的处理方式,不做任何处理
 * @author: yx
 * @date: 2021/12/8 10:20
 *
 * <p>
 */
@Slf4j
public class DefaultParser {

    //默认处理方式,对任何数据都不做处理,直接转发
    public void dealChannel(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, Object msg) {
        channel.writeAndFlush(msg);
    }

    /**
     * 可以对删除语句自行做控制,这里只做日志记录
     *
     * @param ctx
     * @param config
     * @param channel
     * @param sql
     */
    void delete(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, String cmd) {
        InetSocketAddress inetSocketAddress = (InetSocketAddress) ctx.channel().remoteAddress();
        log.info("{}主机在{}上执行了删除操作:{}", inetSocketAddress.getAddress(), config.getRemoteAddr(), cmd);
    }

    /**
     * 可以对修改语句自行做控制,检验或拦截,这里只做日志记录
     *
     * @param ctx
     * @param config
     * @param channel
     * @param sql
     */
    void update(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, String cmd) {
        InetSocketAddress inetSocketAddress = (InetSocketAddress) ctx.channel().remoteAddress();
        log.info("{}主机在{}上执行了修改操作:{}", inetSocketAddress.getAddress(), config.getRemoteAddr(), cmd);
    }

redis报文解析器

代码解析

/**
 * @description: 处理redsi返回的数据报文对某些key进行脱敏处理,这里只处理key包含phone的
 * @author: yx
 * @date: 2022/2/18 9:17
 */
@Slf4j
public class RedisParser extends DefaultParser {
    //处理当返回数据太大分包的情况,暂时不考虑
    Map<String, ByteBuf> bufferMap = new HashMap();
    ///因为存储需要挎会话常规变量无法共享暂时做成静态的变量便于测试,后续再优化其他方案
    static Map<String, String> cmdMap = new HashMap();
    static Set cmdSet = new HashSet();
    //需要脱敏的key,后续需要做成可配置的,这里暂时写死便于测试
    static Set keySet = new HashSet();

    static {
        cmdSet.add("GET");
        cmdSet.add("LRANGE");
        cmdSet.add("SMEMBERS");
        cmdSet.add("HGET");
        cmdSet.add("ZRANGE");
        cmdSet.add("SSCAN");
        cmdSet.add("HGETALL");
        cmdSet.add("HSCAN");
        keySet.add("phone");
    }

    String split = new String(new byte[]{13, 10});

    public void dealChannel(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, Object msg) {
        Channel ctxChannel = ctx.channel();
        InetSocketAddress inetSocketAddress = (InetSocketAddress) ctxChannel.remoteAddress();
        String hostString = inetSocketAddress.getHostString();
        int port = inetSocketAddress.getPort();
        ByteBuf readBuffer = (ByteBuf) msg;
        if (Objects.equals(config.getRemoteAddr(), hostString) && Objects.equals(port, config.getRemotePort())) {
            dealResultBuffer(ctx, config, channel, readBuffer);
        } else {
            dealCmdBuffer(ctx, config, channel, readBuffer);
        }
    }

    void dealCmdBuffer(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, ByteBuf readBuffer) {
        String localPid = ctx.channel().remoteAddress().toString();
        String cmdContent = readBuffer.toString(Charset.defaultCharset());
        String[] split = cmdContent.split(this.split);
        //这里有可能有其他命令不足三位的,暂时不处理
        if (split.length > 2) {
            //获取命令的数量
            Integer integer = Integer.valueOf(split[0].replace("*", ""));
            //没啥用,只是暂时这样处理验证报文正确性后续好处理逻辑
            if (integer * 2 + 1 != split.length) {
//            throw new RuntimeException("命令格式解析错误");
            }
            //获取命令类型
            String cmd = split[2];
            //如果扫描到命令是需要拦截的就存入map后续处理
            if (cmdSet.contains(cmd.toUpperCase())) {
                //获取key,一般是第五个为key,有问题后续再处理,hashkey单独处理,目前只处理前面的大key
                String key = split[4];
                //因为只配置了一个key,所以改成包含phone的就脱敏,便于测试
                if (key.contains("phone")) {
                    cmdMap.put(localPid, key);
                }
            }
        }
        readBuffer.retain();
        channel.writeAndFlush(readBuffer);

    }

    void dealResultBuffer(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, ByteBuf readBuffer) {
        String localPid = channel.remoteAddress().toString();

        String content = readBuffer.toString(Charset.defaultCharset());
        if (cmdMap.containsKey(localPid)) {
            String resultContent = maskValue(content);
            readBuffer.writerIndex(0);
            readBuffer.readerIndex(0);
            readBuffer.writeBytes(resultContent.getBytes());
            readBuffer.writeByte(13);
            readBuffer.writeByte(10);
            cmdMap.remove(localPid);
            channel.writeAndFlush(readBuffer);
        } else {
            readBuffer.retain();
            channel.writeAndFlush(readBuffer);
        }

    }

    //处理整个结果返回脱敏后的结果,需要忽略一些系统报文格式的内容
    String maskValue(String content) {
        List<String> strings = Arrays.asList(content.split(this.split));
        for (int i = 1; i < strings.size(); i++) {
            String contetnValue = strings.get(i);
            if (contetnValue.startsWith("$") || contetnValue.startsWith("*")) {
                continue;
            }
            strings.set(i, replaceStr(contetnValue));
            //间隔是2,所以这里这里索引手动加1
            i++;
        }
        return StringUtils.join(strings, split);
    }

    /**
     * 这里固定脱敏百分之30到70%,后续再改写
     *
     * @param content
     * @return
     */
    String replaceStr(String content) {
        int start = (int) (content.length() * 0.3);
        int end = (int) (content.length() * 0.7);
        //不确定需要脱敏的长度是多少,先这样处理后续再优化
        List<String> replaceContent = new ArrayList<>();
        for (int a = start; a < end; a++) {
            replaceContent.add("*");
        }
        String join = StringUtils.join(replaceContent, "");
        return content.substring(0, start) + join + content.substring(end);
    }


}

测试方案

前提条件

配置的只脱敏key包含phone的数据,其他数据不受影响,后续再做成key可配置的。

测试脚本及命令

set phoneA 13542156548
get phoneA


lpush phoneB 13542156897
lpush phoneB 13542114578
lrange phoneB 0 -1

zadd phoneC 1 13542159875
zadd phoneC 3 13684212456
zrange phoneC 0 -1 WITHSCORES


sadd phoneD 14569871235
sadd phoneD 15465421589
SMEMBERS phoneD


HSET phoneE zhangsan 13565421548    
HSET phoneE lisi 15698745215
HGET phoneE zhangsan
HGETALL phoneE

最终效果

java将文件缓存到内存 java缓存数据_java将文件缓存到内存_02

思考

如果是代理redis集群各个节点之前的无缝切换能实现吗,这个问题不做回答,留着给大家实验一下就知道了。