Eolink 前端负责人黎芷君进行了《工程化- CI / CD》的主题演讲,围绕 CI/CD 管道安全的实践,分享自己在搭建 CI/CD 管道过程中所总结的重要经验,与开发者深入讨论 “前后端” 那些事儿。

随着互联网越来越受重视,前端开发不再是简单的实现一个界面,使用 Javascript 让页面有一定的交互特效。

在同一个时期的迭代里,我们可能需要同时开发浏览器应用、桌面端,甚至是 App、小程序等等。导致了我们迫切的需要考虑一种新的方式,优化我们前端的开发工作。而 CI/CD 是工程化的重要环节之一。

为什么需要 CI/CD ?

我们每次项目迭代过程中都会听到的各种抱怨:来自测试的抱怨、开发的抱怨,甚至是技术主管、运维的抱怨......

时间一长,很可能会导致同一个项目组的成员关系越来越差,项目的质量也不会好。在项目迭代过程中的 5 大现状:

或许有人会说:项目发版一年只有那么几次,比起项目快速的迭代,搭建 CI/CD 系统只是一件必要但是不紧急的事情。

我们先来看看 GitLab 2020 DevSecOps 的调查数据统计:

频繁的发版,可能导致我们每天都得耗在发版里,根本没有时间做新的迭代。这尚且是 2020 年的数据统计,如今已是 2022 年,发版只可能更加频繁。

那么,搭建 CI/CD 系统还是一件不紧急的事情吗?

什么是 CI/CD ?

CI/CD 起源于 70 年代,软件工程的概念被提出,告诉我们不仅需要会开发软件,还需要系统的、规范的开发和维护软件,这标志着工程化意识的觉醒。

直到几十年后,2015 年比尔团队的《凤凰项目: 一个 IT 运维的传奇故事》这本书才介绍了 CI/CD 的雏形。现今,CI/CD 已被广泛地提起以及应用。

从字面意思理解,CI/CD 是由两部分组成的。

CI 指代持续集成,是指我们 Push 代码后对代码进行的一系列质保实践。通过持续集成,我们可以更早地识别和修复错误以及安全问题。CD 是由持续交付和持续部署组成。简单理解是上线过程的一组实践,减少人为误操作的风险。简单的理解就是,CI/CD 是持续集成和持续交付结合的一组实践。

传统上我们将新代码从提交到生产中所需的大部分或全部都是人工干预,例如构建、测试和部署,以及基础设施的配置等等。而 CI/CD,是将一切都自动化了。使用 CI/CD 管道,开发人员只需将更改后的代码 Push 上代码仓库,然后 CI/CD 管道会自动构建和测试,最后进行交付和部署。

深入了解 CI/CD

回顾完整的 CI/CD 过程图,我们可以发现版本控制和自动化测试是整个 CI/CD 管道中重要的两个环节。

版本控制在 CI/CD 中主要用于触发 CI/CD 流水线,它还有个分支管理策略,用于针对不同的环境的特殊场景做隔离处理。

自动化测试主要是使用自动化的手段去执行测试,包括单元、集成、性能和验收等等。它可确保假设某一环节测试失败了,则阻止将功能部署到生产中,并且提升了我们代码本身的质量等等作用。

以下是 CI/CD 搭建的基本原则:

搭建 PC 项目的 CI/CD 管道实例

怎么去搭建 PC 也就是 electron 项目的 CI/CD 管道呢?

以 Eolink Apikit 项目作为示例,以下是我们搭建 CI/CD 系统的步骤:

先给大家解释一下项目背景, Eolink Apikit 平台除了提供 Web 浏览器应用外,还需要同时提供了 PC 桌面端应用,并且是经常性一起发版。

对 Web 应用来说,相对好一些,市面上很多现成的 CI/CD 方案能够参考。但是 PC 由于存在各种难点,例如需要绑定机器资源、代码签名等等,导致了它的 CI/CD 构建在一定程度上难以解决,并且市面上相关的资料也比较少,于是我们花了半个月时间,逐个去攻破难点,形成了现在的方案。

Eolink Apikit 项目的痛点,除了前面提到的缺乏可见性不存在之外,其他四个痛点:项目交付周期长、项目质量参差不齐、重复的执行测试、部署等操作以、发版等待时间过长,都一一具备。

难点

在我们 Eolink 的项目中,自动化测试是由三个环节组成,分别是:

因为单元测试、质量&安全检测由于都是对代码的扫描以及测试,所以不存在表现不一致的情况。而端到端测试 ,它跟操作系统是有一定关系的,例如 Windows 下,关闭按钮在右上角,而 Macos 是在左上角。

那么,我们是怎么解决的呢?

当时我们是在端到端测试的入口配置中引入了 Os 这个库,通过配置 TestMatch 这个字段解决的,例如当前运行端到端测试的操作系统是 Windows 时,我们的 TestMatch 设置匹配文件名的规则为 *.windows.spec.ts。这样,对于依赖操作系统的用例场景,我们就可以快速的独立处理了。

为什么我们不建议直接在具体的用例脚本里面引入 Os 包呢?因为其实我们绝大部分的场景用例都是通用的。所以针对它们,我们应该直接在 CI/CD 管道机器上运行。而针对特殊的场景用例,我们在构建完代码后,将它们和构建包一起分发到对应的操作系统,之后再在构建程序前跑一下就可以了。

针对第二个难点 “代码签名需要物理硬件”,我们先看一下不进行代码签名会怎么样?

上面两张图就是假设没有进行代码签名,Macos 和 Windows 各自的表现。虽说可以忽略,但是总归对公司形象不太友好。Macos 代码签名证书是电子凭证的,所以我们构建应用程序的时候只需要存在证书文件 ,使用 P12 & 密钥就可以了。

Windows 才是问题的所在,首先我们选用的 Windows 签名证书是 EV 代码签名,相较于标准代码签名,它不需要当应用程序下载量达到某一个值时才可以生效,并且可以直接对内核模式驱动签名。

不过坑就随之而来了,它的证书是存储在一个称为 Yubikey 的硬件里的。如果你要随时随地签名,那么你就需要时时刻刻把你的 Yubikey 带上。

这也有办法解决,我调研了很多 EV 签名的公司,发现 ssl.com 是可以进行云签名的。但是,也因为是独此一家,它的费用比较高,100$ 每个月。假设你不介意,这也是个不错的选择。

难点三 “构建应用和操作系统强绑定” ,主要由于我们的 PC 框架是 electron,假设我们需要打 Windows 应用程序,就得先拥有一台 Window 操作系统的物理机器 ,再在里面打包我们的 electron 项目。Macos、Linux 也是以此类推,得在对应的操作系统上打包,否则就会报错。

难点四的 “通信” 问题,主要是由于我们物理机器和 CI/CD 管道所在的服务器不是同一台服务器所导致的。最后是通过将打包的物理机器和 CI/CD 管道环境用 Openvpn 形成内网解决。

难点五 “Web 应用和 PC 应用代码平滑同步” ,则是由于 Eolink apikit 项目是同时存在 Web 和 PC 两个应用的,它们的代码大部分相同,只有个别体验可能不太一样。因此,我们不能完全使用一套代码,这就不属于 CI/CD 的范畴了。

解决方案

主要问题在工具选型上,市面上的 CI/CD 工具很多,但是根据我们想要的形态,可直接归为两类,Github action 以及其他。

Github action 为什么可以独占一类呢?Github action 是 Microsoft 的一个较新的 CI/CD 平台,支持运行在 Linux、MacOS、Windows ,甚至是 ARM 运行器上。它之所以可以独占一类是因为它巧妙的运用了 Matrix ,也就是矩阵策略。

前面我们提到 “应用程序构建和操作系统强绑定” 这一难点, Github action 让我们可以直接无视它。Github action 的 Job 是支持绑定一个叫 Os 的变量的,表达的意思是你可以是在这款操作系统上运行你的任务,可以设定为 Macos 、Windows 、Linux 等等。

我们公司最近推出的 Eoapi 这款开源项目,它的 CI/CD 流水线就是使用 Github action 搭建的。

但是 Eolink Apikit 项目并不使用 Github action ,为什么呢?最主要的的原因就是 Windows 签名硬件这个坑。其他的 CI/CD 工具基本是大同小异,选择用哪个取决于开发团队的需求,毕竟适合自己的才是最好的。我们 Eolink apikit 项目使用的代码仓库管理工具是 Gitlab ,秉着一路走到底的原则,选择的 CI/CD 工具也是 Gitlab。

以上是 Eolink apikit 最终设计的管道流程,当开发 Push 代码后,将会自动触发 CI/CD 流水线,根据我们设定好的分支管理策略将流拆成两条。

针对临时分支,开发可能会经常 Push 。这种时候,我们就不在流水线上做任何测试覆盖率的要求。

针对其他分支,我们将自动构造和单元测试、质量检测并行执行。在这个过程中,单元测试覆盖率是要求 80%,质检需要各个指标为 0。后面就是常规的端到端测试,覆盖率同为 80%。都没问题的话,就直接将编译好的代码和针对操作系统的用例分发给各个操作系统,以及最后上传代码。过程中假设出现任意错误,就会马上停止后面的流程。自动触发告警,并将相关的错误信息发送给提交者。

从上图我们可以看到详细的执行步骤,如果还没执行完,还可以看到当前哪些 Job 正在执行,哪些 Job 在 Pending。点击每个 Job ,可以看到具体的 Job 执行信息,发生错误还可以针对这个 Job 进行重试。最后,我们告警系统接入的是飞书机器人。假设执行过程中发生任意错误,都会通过飞书机器人发送通知给相应人,让他们马上回来调整。

关于 electron CI/CD 管道的搭建,总结了几条建议,分享给大家:

CI/CD 管道安全的实践

我们为什么需要关注 CI/CD 管道安全呢?

从数据中,我们可以看到 2021 年世界上的软件供应链攻击增加了 6 倍多(650%)。同时 Gartner 也预测,到 2025 年全球会有将近 45% 的企业遭遇攻击。

CI/CD 管道攻击就属于供应链攻击。CI/CD 是我们软件开发周期的重要组成部分,假设我们忽略它的安全,就有可能被人为攻击管道漏洞,直接窃取我们的敏感信息,甚至是交付恶意代码。

典型的案例有 2020 年 12 月的 Solar Winds 供应链攻击事件,以及去年的攻击者入侵了数千名开发人员使用的 Bash 上传器的 Codecov 供应链攻击事件等等。

诸多案例告诉了我们,提高 CI/CD 管道的安全性迫在眉睫。应该怎么做才能避免呢?关键在于我们需要知道具体有哪些可能性风险,才能去逐一攻破。

在此,我们结合信息安全三要素进行初步的分析,可以了解到 CI/CD 管道涉及敏感数据泄露是造成风险的主要因素,如 IP 、密钥泄漏或者是漏洞的披露,而源码被植入后门、恶意挖矿或者是其他恶意行为是供应链攻击的主要一环。

十大 CI/CD 安全风险

风险1

不完善的流量控制机制导致的风险。

我们搭建 CI/CD 关注的更多是怎么提效,往往会忽略它的安全。例如攻击者会利用 CI 中分支保护规则的漏洞,绕过审查去发布恶意代码。

典型的案例是去年 4 月份在 PHP github 仓库中植入后门的事件,以及上年 10 月份攻击者使用 GitHub Actions 漏洞绕过审查机制的事件。

我们应该怎么去防范呢?老话说得好,吃哪补哪,所以针对这个风险最好的方式是建立完善的管道流量控制机制,以确保没有人或者软件能够在没有验证的情况下通过管道传送恶意的代码或者软件。例如,我们可以在受保护的分支上配置分支保护规则,所有用户提交的代码都得经过它去做审查才能去发布。

风险2

假设我们的 CI/CD 环境存在很多身份凭证,不管是授予机器的还是人的。

一旦管理好,比如为了前期减少沟通成本,我们将所有账号都赋予管理员权限,就有可能被攻击者利用,想干什么就干什么。

典型案例是 2019 年 Stack Overflow TeamCity 发生了一起安全事件,当时出现了一个没有人认识的账号,获得了 Stack Exchange 网络中所有站点的审核者和开发人员级别的访问权限。官方追踪后,发现是因为新注册的账号在访问系统时会被自动分配管理员权限。

由此可见,我们需要避免创建本地账号。或者,我们可以使用像 Idap 这种集中式企业工具创建和管理账号。

有人会说我不管,我就得创建本地账号。那么我们就需要确保能够定时清理账号,以及所有账号的安全策略需要与企业策略是一致的。

风险3

相信绝大部分团队都为项目引用过一些第三方开源组件,因为比起自己去实现,第三方开源组件有时候会考虑得更加全面一些,并且直接引用也更快。但是,我们千万不要去滥用它。

首先,我们没法保证引入的第三方开源组件是没有漏洞的。像上年的 Apache 日志控件被爆出远程代码执行漏洞,连这么稳定的包都可能有漏洞,其他的又怎么能确保完全安全呢。

其次,我们没法知道第三方开源资源的贡献策略是怎么样的,是否做了安全检测。这导致攻击者有可能拥有访问开源组件仓库的权限,可以直接为开源组件添加恶意后门程序,并对外发布。

这样,很容易引发大规模的供应链攻击。攻击者可以利用该后门对 CI/CD 环境进行探测,进而导致整个环境沦陷。

最后,在 CI/CD 管道中,我们通常会引入一些第三方工具对项目进行管理。例如 Nodejs 项目中,通常会引入 Npm 仓库,若项目直接从 Npm 中央仓库去拉组件,就无法确定是否会引入了含有漏洞的组件,进而可能导致组件漏洞被攻击者利用。

针对这种风险,我们建议首先梳理项目中所有依赖的开源组件,可以通过 SBOM 进行梳理,并采用软件成分分析工具对我们引入的开源资源进行漏洞扫描。当项目中引入了新的开源资源时,也能够具备针对性的安全管控措施。

风险4

我们称为 PPE,是“中毒”管道的执行所导致的,主要是中了攻击者恶意代码或者恶意命令的毒。

根据 “中毒” 的手段,PPE 分为以下三种类型:

  • 第一种是攻击者直接修改有权限访问的管道配置文件,在管道运行中触发恶意命令来达到攻击我们的效果,我们又称之为直接 PPE (D-PPE) ;

  • 第二种是攻击者通过向管道配置中所引用的文件注入恶意代码,来间接的毒害管道;

  • 最后一种是攻击者假设需要通过获取身份凭证来访问管道配置文件,那么他可以通过破坏公共项目达到攻击私有项目的效果,从而挖掘更多的敏感信息。

典型案例是上年 GitHub Actions 通过包含恶意代码的拉取请求滥用来挖掘加密货币事件。具体的解决方案是需要从之前对代码的审查,改进为现在同时需要对管道配置文件也进行审查,甚至是定时监控管道的运行情况。

风险5

基于管道的访问控制不足所导致的风险。

它的存在将导致攻击者直接将一段恶意代码注入到管道执行节点的上下文中,这样,恶意代码就可以具有运行管道阶段的完全权限,可以访问机密信息、访问底层主机,甚至访问连接到相关管道的任何系统。

毋容置疑的将会导致我们机密数据泄露、CI 环境内的横向移动,以及被恶意软件部署到我们管道中,甚至是发布到生产环境中。

Codecov 事件中,就是因为疏于防范导致 Codecov 最终被破坏并用于从构建中窃取客户的环境变量。我们要怎么规避它呢?重点就是做好权限管理,例如 CI/CD 管道作业在控制器节点上的权限应该设置有限。

风险6

假设我们已经拥有了身份和访问认证机制了,但是凭证管理不妥当也是会导致风险的。

例如,开发将包含凭证的代码推送到代码仓库中,不管是有意还是无意的,这都将会导致我们将凭证暴露给对代码仓库具有访问权限的人。即使后面我们发现不对,马上将它从被推送到的分支上删除,他们还是会在提交历史记录中出现。

调试时将凭证打印到控制台中,可能会使凭证以明文方式在日志中公开,任何有权访问构建结果的人都可以查看。这些日志后面也可能会流入到日志管理系统中,从而扩大了凭证的暴露面。

回看Codecov 攻击事件,攻击者就是通过破坏 CI 中的凭证,去窃取了存储为环境变量的数千个凭证。解决方案中最重要的就是,不要在 CI/CD 环境中存储任何敏感的信息,至于其他都是次要的。

风险7

CI/CD 环境中不安全配置的系统导致的风险。

攻击者利用有漏洞系统的安全漏洞来获得对系统未经授权的访问,或者更糟的情况是,破坏了系统并访问其他底层操作系统。

都有哪些不安全的操作系统呢?例如使用过时版本或缺少重要安全补丁的自我管理系统。或者是对底层操作系统具有管理权限的自托管系统。 SolarWinds 构建系统的入侵就是典型的案例。除了以上的防范手段,还需要去定时为系统打补丁。

风险8

和第三方开源资源滥用一样,属于无监管使用导致的风险。

缺乏对第三方服务的治理,会严重影响企业在其 CI/CD 系统维护管道中对于角色访问控制的操作,企业也会变得很被动,安全性得取决于它们实施的第三方。

而第三方的最小特权,加上围绕第三方实施过程的最小治理和尽职调查,都会显著增加企业的攻击面。

鉴于 CI/CD 系统和环境的高度互联性,假设第三方的漏洞被利用,造成的危害是远远超出第三方所连接的系统范围的损害的。例如,具有写入权限的第三方存储库,攻击者可以将恶意代码推送到存储库,第三方存储库反过来又会触发构建,并在构建系统上运行攻击者的恶意代码。

这个风险防范手段很简单,主要是围绕第三方服务的治理控制,我们应在第三方使用生命周期的每个阶段去实施。

风险9

CI/CD 流程是由多个步骤组成的,最终负责将代码从仓库一路带到生产环境。

每个步骤都可能有多个资源,最终软件包依赖于分布在不同步骤中的多个来源,它们是由多个贡献者提供的,从而让攻击者有可能通过这些入口点去篡改最终的软件。如果被篡改的软件成功地渗透到交付过程中,而不引起任何的怀疑或者没有遇到任何安全检测,它很可能就会直接以合法资源的名义继续流经我们的管道,一直发布到我们的生产环境中。这就是不正确的软件完整性验证导致的风险。防止不正确的软件完整性验证风险需要一系列措施,跨越软件交付链中的不同系统和阶段。

风险10

强大的日志系统是能够帮助企业准备、检测以及调查相关安全事件的,但是如果它不够强大,那就会引来风险。

随着攻击者逐渐将注意力转移到工程环境漏洞中,那些无法确保围绕这些环境进行适当的日志记录和可见性控制的企业,就有可能无法检测到违规的行为。

解决方案有哪些呢?虽然工作站、服务器以及业务应用程序通常在企业的日志记录和可见性程序中得到深入介绍,但工程环境中的系统和流程通常并非如此。

鉴于利用工程环境和流程的潜在攻击向量的数量,我们必须建立适当的能力以在这些攻击发生时立即检测到它们。其中许多载体涉及利用针对不同系统的程序化访问,面临这一挑战的关键就是围绕人工和机器访问创建强大的可见性。

鉴于 CI/CD 攻击向量的复杂性,系统的审计日志(用户访问、用户创建、权限修改)和应用日志(将事件推送到存储库、执行)需要具有同等的重要性构建以及上传软件。

总结:持续集成、自动化测试和持续部署

最后还需要注意的是,随着业务越发复杂,系统架构从单体走向微服务,CI/CD 管道的复杂性也会相应增多,每个阶段都可能会产生大量的敏感数据,这些敏感数据往往会成为巨大的攻击杠杆。

试想一旦攻击者拿到了源码仓库的访问凭证,那么整个 CI/CD 环境都可能遭到沦陷

在 Gitlab 的 CI/CD 起因统计报告的最后有这样一句话:

我们之所以这么频繁的发布程序,是因为采用了 DevOps 方法,并且主要归功于 CI/CD 中的持续集成、自动化测试和持续部署。

当然,安全也义不容辞。

如何搭建安全的 CI/CD 管道?的更多相关文章

  1. 基于Gogs+Drone搭建的私有CI/CD平台

    请移步 基于Gogs+Drone搭建的私有CI/CD平台

  2. 创建和使用CI / CD管道【译】【原】

    在GitLab 8.8中引入. 介绍 管道是持续集成,交付和部署的顶级组件. 管道包括: 定义要运行的作业的作业.例如,代码编译或测试运行. 定义何时以及如何运行的阶段.例如,该测试仅在代码编译后运行 ...

  3. Linux搭建.net core CI/CD环境

    一.简介 微服务开发中自动化.持续化工程十分重要,在成熟的CI/CD环境中项目团队可以灵活分配,大大提供团队效率.如果还不了解什么是CI/CD,可以先查看相关文章,这里主要介绍环境的搭建,相关原理就不 ...

  4. Gitea 与 Drone 集成实践:完全基于 Docker 搭建的轻量级 CI/CD 系统

    Drone 是一个使用 Go 语言编写的自助式的持续集成平台,和 Gitea 一样可以完全基于容器部署,轻松扩展流水线规模.开发者只需要将持续集成过程通过简单的 YAML 语法写入 Gitea 仓库目 ...

  5. GitLab CI/CD的官译【原】

    CI / CD方法简介 软件开发的持续集成基于自动执行脚本,以最大限度地减少在开发应用程序时引入错误的可能性.从新代码的开发到部署,它们需要较少的人为干预甚至根本不需要干预. 它涉及在每次小迭代中不断 ...

  6. DevOps 什么是 CI/CD?

    CI/CD 是一种通过在应用开发阶段引入自动化来频繁向客户交付应用的方法.CI/CD 的核心概念是持续集成.持续交付和持续部署.作为一个面向开发和运营团队的解决方案,CI/CD 主要针对在集成新代码时 ...

  7. GitLab CI/CD 配置

    GitLab CI/CD 配置 概念 持续集成的相关概念,可以看这篇文章 持续集成是什么? - 阮一峰的网络日志 操作示例 创建测试项目 sample-web,然后打开项目的 Runners 配置 找 ...

  8. 持续集成指南:GitLab 的 CI/CD 工具配置与使用

    前言 写代码这项工作,本质就是将工作自动化,减少手工操作提供效率,因为人的本质都是懒狗,程序员也不能例外,为了各种意义的效率提升(懒),我们需要持续集成工具,将代码测试.编译.发布这些重复性很高的工作 ...

  9. 【Devops】【docker】【CI/CD】1.docker搭建Gitlab环境

    CI/CD[持续化集成/持续化交付] docker搭建Gitlab环境 1.查询并拉取gitlab镜像 docker search gitlab docker pull gitlab/gitlab-c ...

随机推荐

  1. Future源码一观-JUC系列

    背景介绍 在程序中,主线程启动一个子线程进行异步计算,主线程是不阻塞继续执行的,这点看起来是非常自然的,都已经选择启动子线程去异步执行了,主线程如果是阻塞的话,那还不如主线程自己去执行不就好了.那会不 ...

  2. Kafka 延时队列&重试队列

    一.延时队列 1. 简介 TimingWheel是kafka时间轮的实现,内部包含了⼀个TimerTaskList数组,每个数组包含了⼀些链表组成的TimerTaskEntry事件,每个TimerTa ...

  3. myeclipse添加subclipse插件支持subversion1.9

    为了安装subclipse插件,费了很多周折,本来我以为直接用eclipse marketplace搜索安装就行,可是由于网络原因,安装不了. 然后下载安装包吧.目前从国内网站上下载不了支持subve ...

  4. dolphinscheduler添加hana支持

    dolphinscheduler添加hana支持 转载请注明出处: https://www.cnblogs.com/funnyzpc/p/16395092.html 前面 上一节有讲datax对han ...

  5. 小白对Java的呐喊

    1 public class Hello{ 2 public static void main(string[] args){ 3 System.out.print("hello world ...

  6. DENIED Redis is running in protected mode because protected mode is enabled

    DENIED Redis is running in protected mode because protected mode is enabled redisson连接错误 Unable to i ...

  7. 从零开始完整开发基于websocket的在线对弈游戏【五子棋】,只用几十行代码完成全部逻辑。

    五子棋是规则简单明了的策略型游戏,先形成五子连线者获胜.本课程习作采用两人在线对弈的方式进行比赛,拿着手机在上下班路上玩特别合适. 整个过程在众触低代码应用平台进行,使用表达式描述游戏逻辑(高度简化版 ...

  8. net core 3.1使用identityServer登录时signin-oidc报Correlation failed的解决方法

    此问题全网找了很久,也困扰了我很久,始终没有找到解决方法.今天结合网上其他问题的帖子,自己研究的半天,终于找到了这个解决方法,经亲自测试可行.欢迎大牛指导指正. 有时客户收藏的系统地址是认证端的,然后 ...

  9. ElementUI嵌套页面及关联增删查改实现

    @ 目录 前言 一.ElementUI如何在原有页面添加另外一个页面并实现关联增删查改? 二.实现步骤 1.ElementUI代码 2.思路:很简单 1.1 首先通过el-row.el-col.el- ...

  10. 吐泡泡_via牛客网

    题目 链接:https://ac.nowcoder.com/acm/contest/28537/E 来源:牛客网 时间限制:C/C++ 1秒,其他语言2秒 空间限制:C/C++ 32768K,其他语言 ...