openUBMC社区编程规范
[TOC]
1 概述
- 本规范适用于openUBMC社区代码开发,目的是引导社区开发者养成良好的编程习惯,编写出风格统一、容易阅读、安全可靠的代码
- 本规范同样适用于指导openUBMC社区门禁和工具开发
2 代码风格
2.1 命名
P.01 标识符的命名遵循阅读习惯
符合英文阅读习惯的命名将明显提高代码可读性。命名贴近英文语法,使代码更容易阅读
【正例】
if found then
...
end
【反例】
if is_found then
...
endP.02 变量命名尽量简短,前提是不影响阅读理解
变量应在其作用域中无二义性,在不影响阅读理解的前提下,命名应该尽量简短
【正例】
local function print_error_msg()
local msg
...
end
【反例】
local function print_error_msg()
local error_msg
...
endG.NAM.B01 标识符满足openUBMC标识符命名规范
结合openUBMC的业务特点和现状,针对Lua语言的标识符命名制定了如下规范:
表1.1 openUBMC Lua语言标识符命名规范
| 类别 | 规定命名风格 | 正确示例 |
|---|---|---|
| 仓库名 | snake_case风格 | libmc_lua |
| 组件名 | snake_case风格 | key_mgmt |
| 文件 | snake_case风格 | project_file.lua |
| 模块 | snake_case风格 | local module_name = {} |
| 类 | snake_case风格 | local class_name = {} |
| RPC方法 | 大驼峰风格 | function module_name:SetName() |
| RPC方法参数 | 大驼峰风格 | function module_name:SetName(ExampleName) |
| 函数 | snake_case风格 | local function func_name() |
| 全局变量 | snake_case风格 | var_name = 0 |
| 局部变量 | snake_case风格 | local var_name = 0 |
| 函数参数 | snake_case风格 | local function set_name(example_name) |
| 常量 | 全大写,下划线分割 | local DEFAULT_PORT = 8080 |
| 枚举类型 | snake_case风格 | local enum_name = {} |
| 枚举值 | 全大写,下划线分割 | local enum_name = { MODE_RED = 0, MODE_GREEN = 1 } |
| 表名 | snake_case风格 | local table_name = {} |
| 表索引 | snake_case风格 | local table_name = { table_index = 'example' } |
注:表中未包含的其他标识符命名,建议统一使用snake_case
G.NAM.B02 函数和参数的命名能够正确描述功能
- 命名被认为是软件开发过程中最困难,也是最重要的事情之一
- 符号命名要简洁、准确,符合阅读习惯,容易理解
G.NAM.B03 标识符拼写正确,符合通用习惯缩写
表1.2 常用英文缩写对照表
| 完整单词 | 缩写 | 完整单词 | 缩写 | 完整单词 | 缩写 | 完整单词 | 缩写 |
|---|---|---|---|---|---|---|---|
| action | act | address | addr | argument | arg | asynchronous | async |
| attribute | attr | average | avg | buffer | buf | calculate | calc |
| class | cls | column | col | command | cmd | compare | cmp |
| configuration | cfg | context | ctx | control | ctrl | count | cnt |
| current | cur | decrease | dec | define | def | delete | del |
| descriptor | desc | destination | dest | directory | dir | division | div |
| document | doc | driver | drv | environment | env | error | err |
| ethernet | eth | execute | exec | expression | expr | frequency | freq |
| function | func | generate | gen | hexdecimal | hex | identification | id |
| image | img | increase | inc | index | idx | information | info |
| initial | init | interface | intf | length | len | library | lib |
| management | mgmt | maximum | max | message | msg | minimum | min |
| module | mod | multiple | multi | number | num | object | obj |
| package | pkg | parameter | param | password | pwd | physical | phy |
| pointer | ptr | position | pos | previous | prev | process | proc |
| public | pub | receive | rcv | reference | ref | register | reg |
| repository | repo | response | resp | result | ret | second | sec |
| sequence | seq | serial number | sn | source | src | standard | std |
| string | str | synchronize | sync | system | sys | temperature | temp |
| temporary | tmp | value | val | variable | var | vector | vec |
| version | ver | voltage | volt |
2.2 注释
P.03 注释跟代码一样重要,应按需注释
G.CMT.B01 较为晦涩的代码、特殊处理场景代码要尽量详尽的进行注释
- 难理解的代码,建议在注释中简述相关知识说明,比如增加协议字段格式、自定义配置文件格式等方便理解代码处理逻辑
- 代码中的特殊处理,尽量介绍清楚背景、业务场景、处理逻辑等;也可以在README中增加功能详细说明
- 当需要规避周边组件(硬件、开源第三方软件等)的问题时,必须添加注释并且注明规避背景、规避原因和影响范围
3 编程实践
3.1 表
P.04 禁止弱引用表与__gc元方法混用
- openUBMC基于LuaJIT开发,LuaJIT只完全兼容Lua 5.1。当弱引用表与__gc元方法混用时可能会引发无法预期的错误
【反例】
local table_a = setmetatable({}, {__mode = 'kv', __gc = function()
...
end}) -- Bad, 弱引用表与__gc元方法混用G.TBL.B01 Lua 表不支持直接比较,需要比较所有的Key和Value是否相同
可以使用框架utils.table_compare库函数完成Lua 表的比较
【反例】
local table_a = {}
local table_b = {}
...
if table_a == {} then -- Bad
...
end
if table_a == table_b then -- Bad
...
end
【正例】
local table_a = {}
local table_b = {}
...
if utils.table_compare(table_a, {}) then -- Good
...
end
if utils.table_compare(table_a, table_b) then -- Good
...
end
if next(table_a) == nil then -- Good
...
endG.TBL.B02 表使用的时候需要保证先构造成员变量然后使用,避免多线程下出现访问nil
【反例】
-- 线程一
local value = {
['Type'] = 'ABC'
}
-- 线程二
-- 可能先于线程一执行,value.Type为nil导致程序抛错
table_a[value.Type] = 1
【正例】
-- 划分到同一个线程先后执行,保证成员变量已构造
local value = {
['Type'] = 'ABC'
}
table_a[value.Type] = 1G.TBL.B03 使用ipairs有序遍历数组,使用pairs遍历不连续的数组
- 不论遍历的是数组还是字典,pairs均不保证遍历的顺序,它依赖于表内部的哈希实现。
- 要有序遍历数组,使用ipairs遍历。这里的有序是指从下标1开始依次访问,下标必须连续。table的键值如果包含字母则不属于数组的范畴,更谈不上有序。
- 要完整遍历不连续的数组,使用pairs遍历
【反例】
local a = { 1, 2, 3, 4, 5 }
for k, v in pairs(a) do
print(v) -- Bad, 使用pairs遍历,不保证顺序
end
local b = {
[1] = '1',
['a'] = 'a'
}
for k, v in ipairs(b) do
print(v) -- Bad, 使用ipairs遍历,未完整遍历所有数据
end
【正例】
local a = {1, 2, 3, 4, 5}
for k, v in ipairs(a) do
print(v) -- Good, 使用ipairs遍历有序数组
end
local b = {
[1] = '1',
['a'] = 'a'
}
for k, v in pairs(b) do
print(v) -- Good, 使用pairs遍历所有元素,但不保证顺序
endG.TBL.B04 使用#对表取长度应当先确保表是一个序列且未包含其他类型键值
- 如果表的__len元方法没有给出,表的长度只在表是一个序列时有定义
- 序列指表的正数键集等于 {1..n}
【反例】
local a = {
[2] = 1,
[3] = 2
} -- Bad,正数健集{2,3},缺少键值1,a不是序列
local b = {
[1] = 1,
[3] = 2,
[4] = 3
} -- Bad,正数键集{1,3,4},键值不连续。禁止对不连续的数组表求长度,结果不可预测
local c = {
[-1] = 1,
[0] = 2,
[1] = 3,
[2] = 4,
['s'] = 5
} -- Bad,正数健集{1,2},c是序列,但不推荐使用#取长度,容易误认为其他键值也参与长度计算
【正例】
local d = {
[1] = 1,
[2] = 2,
[3] = 3,
} -- Good,正数键集{1,2,3},d是序列,且未包含其他类型键值,#d是预期的3G.TBL.B05 使用remove方法删除表中元素应当先确保表是一个序列且未包含其他类型键值,否则应当使用将元素置为nil的方式删除
- 序列指表的正数键集等于 {1..n}
【反例】
local a = {
[2] = 1,
[3] = 2
}
table.remove(a, 2) -- Bad,报错bad argument #1 to 'remove' (position out of bounds)
【正例】
local a = {
[1] = 1,
[2] = 2,
[3] = 3
}
table.remove(a, 2) -- Good,成功删除键为2的元素,删除后该表为{[1] = 1, [2] = 3}
local b = {
[2] = 1,
[3] = 2
}
b[2] = nil -- Good,成功删除键为2的元素,删除后该表为{[3] = 2}注意:使用t[i] = nil会导致数组下标不连续,使用table.remove才会自动调整数组下标
G.TBL.B06 通过pairs遍历表时,循环体中不能同时对表做插入和删除操作
【反例】
local a = { 1, 2, 3 }
for k, v in pairs(a) do
a[k] = nil
a[k + 3] = v -- Bad,报错invalid key to 'next'
endG.TBL.B07 使用Lua弱引用表时确保表外有对变量进行引用,或变量垃圾回收不影响业务功能
- Lua弱引用表(
__mode = 'kv'或__mode = 'k'或__mode = 'v')作为键/值保存在表中的变量不会增加其引用计数,当变量在表外没有被引用时会被垃圾回收
【反例】
-- 弱引用表db_objs用来存放db对象
local db_objs = setmetatable({}, {__mode = 'kv'})
function add_db_obj(obj)
-- 将需要持久化的obj添加到待持久化的对象列表db_objs中
db_objs[obj] = context.get_context() or context.new()
-- 由于db_objs是弱引用表,离开当前函数作用域之后obj可能被垃圾回收
end
local function save_all_objs()
for obj, ctx in pairs(db_objs) do
-- 遍历db_objs时,表中对象可能已经被垃圾回收,造成数据丢失
obj:save()
end
end
【正例】
-- 弱引用表g_obj_pool的作用是提供缓存池,避免频繁重复创建对象
local g_obj_pool = setmetatable({}, {__mode = 'kv'})
local function new_obj()
local obj = table.remove(g_obj_pool)
if not obj then
-- 表中对象已被垃圾回收时可以重新创建,不影响业务功能
return new_object()
end
-- 表中对象没有被垃圾回收时可以重复利用,避免频繁创建新对象的消耗
return obj
endG.TBL.B08 应用插件机制的场景下,推荐使用深拷贝的方式传递table类型参数
- table类型的参数在传入插件处理后,可能会在插件内部被修改。如果该参数后续在组件内需要继续使用,可能会引起异常
- Lua不支持声明const类型的入参,因此推荐使用深拷贝的方式(utils.table_copy)传递table类型参数到插件
【反例】
function plugin_method.set_property(parameter)
local ok, rsp = pcall(func, parameter) -- Bad,table参数直接传入插件
...
end
【正例】
function plugin_method.set_property(parameter)
local input_location = utils.table_copy(parameter) -- Good,深拷贝一份table参数传入插件
local ok, rsp = pcall(func, parameter)
...
endG.TBL.B09 高频调用场景中禁止通过赋空表的方式实现表的清空
- 赋空表会申请新的内存,在高频调用场景中会导致内存无法及时GC,产生内存碎片,最终导致内存持续增长
- 可以通过两种方式实现:1、依次对表的变量赋nil;2、使用封装的table_cache类
local table_cache = require 'mc.table_cache'
local instance = table_cache.new() -- 实例化
local config = instance:allocate() -- 申请空表
instance:deallocate(config) -- 销毁3.2 函数
G.FUN.B01 信号处理函数应该轻量
- 信号处理函数处理时间过长可能会引起程序非预期结果,使用时应谨慎
- 不允许在信号处理函数中添加延时函数,如skynet.sleep
G.FUN.B02 循环体中避免定义local变量,放在循环体外定义,可以减少内存申请
【反例】
for i = 1, #array do
local a
a = array[i].number
...
end
【正例】
local a
for i = 1, #array do
a = array[i].number
...
endG.FUN.B03 使用Lua闭包特性,避免在高频调用函数中申请local变量
- 循环体和高频调用函数中定义
local变量,会反复申请内存,增加Lua GC机制负担,当GC无法及时回收时,会导致内存占用持续增加。
G.FUN.B04 禁止将_作为函数入参
_作为Lua中的占位符,与其他变量并无区别,默认值为nil,未通过local显式申明的情况下为全局变量。以占位符作为函数入参,可能引起异常
【反例】
local res, _ = func(cmd, _, request.data_out) -- Bad, _可能已作为全局变量被赋值
...
function func(cmd, data_in, data_out)
if data_in then -- 如果data_in非空,执行额外命令
...
end
end
【正例】
local res, _ = func(cmd, nil, request.data_out, nil) -- Good, 直接通过nil占位3.3 程序块
G.CHK.B01 Lua除法运算使用'//'时,结果会向下取整。使用'/'时,结果为浮点数
【示例】
local a = 5 / 2
local b = 5 // 2
print('a = ', a) -- 输出为 a = 2.5
print('b = ', b) -- 输出为 b = 2G.CHK.B02 任意基本类型变量都可以进行'=='和'~='比较,但只有数值类型变量可以进行'>'、'<'、'>='、'<='比较
【反例】
if a > b then -- a、b为字符串类型
...
endG.CHK.B03 Lua中基本数据类型是值类型,table是引用类型。对新的值类型变量赋值不会改变原始值
-- 错误示例
local flag = table[Id]
if flag ~= 0 then
...
end
flag = 1 -- 对flag赋值不会改变table[Id]G.CHK.B04 对变量使用#取长度前,须确保变量为string/table类型或者有__len()元方法
- Lua中string/table类型(特指序列,参见G.TBL.B04说明)对#有定义,非string类型可以通过__len元方法修改取长度的操作行为
【反例】
if #v.SRVersion > 0 then -- Bad,未确保变量为table或string类型,程序可能抛错
...
end
【正例】
if type(v.SRVersion) == 'string' and #v.SRVersion > 0 then -- Good,先确保变量为string类型再取长度
...
endG.CHK.B05 北向接口Script和Plugin编码时,使用cjson接口保证字典和数组的有序性
- 北向接口通常要求字典和数组的有序性,使用Lua的table默认无序,可能导致接口返回结果不符合预期
- 在北向提供的Sript和Plugin机制中,建议使用框架提供的cjson接口json_object_new_array/json_object_new_object,保证返回结果的有序性
【反例】
function get_selLogEntries(processObj)
local eventList = processObj.EventList
local selLogEntries = {}
for i, v in pairs(eventList) do
selLogEntries[i] = { -- Bad, 使用Lua的table,无序
["eventid"] = eventList[i].RecordId,
["subjecttype"] = "TODO",
["eventdesc"] = eventList[i].Description,
}
end
return selLogEntries
end
【正例】
function get_selLogEntries(processObj)
local eventList = processObj.EventList
local selLogEntries = cjson.json_object_new_array() -- Good, 使用cjson接口保证数据的有序性
for i, v in pairs(eventList) do
selLogEntries[i] = cjson.json_object_new_object()
selLogEntries[i].eventid = eventList[i].RecordId
selLogEntries[i].subjecttype = 'TODO'
selLogEntries[i].eventdesc = eventList[i].Description
end
return selLogEntries
endG.CHK.B06 使用string.find匹配字符串时,应注意plain参数的正确使用
string.find用于在指定的目标字符串中搜索指定的模式,函数的完整声明为string.find(s, pattern [, init [, plain]])string.find具有两个可选参数,第3个参数是一个索引,用于说明从目标字符串的哪个位置开始搜索。第4个参数是一个布尔值,用于说明是否进行简单搜索(plain search)plain参数不传时,默认为false,表示进行模式匹配,如果传入true,则进行简单搜索,使用时应注意是否与实际查询情况相符- 如果没有第3个参数,则不能传入第4个可选参数
【示例】
-- 从uri中匹配文件后缀名.ISO
-- plain参数设置为true,表示进行简单搜索,不进行模式匹配,'.'不会被视为特殊字符
-- 如果需要传入第4个参数,第3个参数必须也传入,1表示从第1个字符开始搜索
local start_pos = string.find(uri, '.ISO', 1, true)G.CHK.B07 使用字符串匹配时,应注意与标准正则匹配规则的差异
- Lua使用“%”进行转义,而标准正则使用“\”进行转义
- Lua不支持大小写不敏感模式
G.CHK.B08 使用字符串变量时,应注意单双引号的使用
- 如果字符串中有双引号,要用单引号包括;如果字符串中有单引号,要用双引号包括
- 字符串不能又包含单引号又包含双引号
G.CHK.B09 使用A and B or C进行三目运算时,应避免B取值为false
- 当
B取值为false时,无论A为真或假,三目运算的结果都为C,不符合预期 - 针对
B取值可能为false的场景,使用if...else表达式
【反例】
local res1 = (val_a == 1) and false or true -- Bad,结果恒为true
local res2 = (val_a == 1) and (val_b == 1) or true -- Bad,当不满足val_b == 1时,结果恒为true
【正例】
local res1 = (val_a ~= 1)
local res2
if val_a == 1 then
res2 = (val_b == 1)
else
res2 = true
endG.CHK.B10 不推荐使用Lua的goto语句
- goto将程序的控制点转移到一个标签处,Lua机制会检测跳转到的新作用域内的变量是否全部初始化,如果跳过了初始化则会报错
- 这违反了Lua 5.3+的标签作用域规则(ISO/IEC 2375:2017 §3.3.8),实际执行时将触发"undefined label 'xxx'"错误(Lua 5.3+严格模式)
3.4 多线程
G.MTH.B01 openUBMC使用skynet框架,在Lua中创建协程只能使用skynet.fork,禁止使用coroutine
- skynet框架消息处理服务使用了Lua的coroutine来调度各个服务处理消息,在skynet框架中的服务应该使用skynet.fork创建子任务,由skynet统一调度。直接使用Lua的coroutine会和skynet框架的coroutine冲突,产生不可预期的结果。
G.MTH.B02 使用skynet.wait/skynet.wakeup对协程进行挂起、唤醒操作时,避免中途有其他挂起协程的操作
- 当协程使用
skynet.wait/skynet.wakeup对协程进行挂起、唤醒操作时,假如插入其他挂起协程的操作,如skynet.sleep,协程唤醒可能与预期不一致
【反例】
function func()
local co = coroutine.running()
local function check()
...
-- 此处的skynet.wakeup可能唤醒的是skynet.sleep挂起的协程,非代码预期情况
skynet.wakeup(co)
end
-- 创建协程,在协程中唤醒co
skynet.fork(check)
-- skynet.sleep相当于skynet.wait(),挂起当前协程
skynet.sleep(10)
-- 预期在此处挂起和唤醒当前协程
skynet.wait()
co = nil
end
【正例】
function func()
local co = coroutine.running()
local function check()
···
-- 唤醒的为skynet.wait()挂起的协程,与预期相符
skynet.wakeup(co)
end
skynet.fork(function()
skynet.fork(check)
-- skynet.sleep挂起新协程,不会影响原协程
skynet.sleep(10)
end)
-- 把创建协程与skynet.sleep的操作放在新协程中,当前skynet.wait()正常挂起
skynet.wait()
co = nil
endG.MTH.B03 禁止在ORM对象中通过skynet创建协程
- ORM(Object Relational Mapping)是openUBMC实现的一种OOP范式。
- 当ORM对象卸载时,通过skynet创建的协程不会跟随对象消亡。如果协程中存在访问对象的行为,可能会导致程序发生异常。
- 可以通过self:next_tick替代skynet创建协程
【反例】
function c_network_adapter:set_npu_max_sfp_temp()
local ops = c_optical_module.collection:fetch({NetworkAdapterId = self.NodeId})
skynet.fork(function() -- Bad,通过skynet.fork创建协程
while true do
self:update_npu_max_sfp_temp(ops)
end
end)
end
【正例】
function c_network_adapter:set_npu_max_sfp_temp()
local ops = c_optical_module.collection:fetch({NetworkAdapterId = self.NodeId})
self:next_tick(function() -- Good,通过ORM自带方法创建协程
while true do
self:update_npu_max_sfp_temp(ops)
end
end)
endG.MTH.B04 避免重复初始化同一个文件锁
- 通过flock函数对某文件加完互斥锁之后,当文件锁被再次初始化时(代码流程可能会重复调用初始化函数),文件描述符会发生变化
- 锁释放时操作的是变化后的文件描述符,文件未成功解锁,当再次加锁时就会导致代码流程死锁
/* 示例 */
int32 db_init(void)
{
// 确保只初始化一次
if (db_is_inited()) {
return RET_OK;
}
// 初始化文件锁
g_db_lock = open(DB_SYNC_FILE, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
...
}3.5 数据库
G.DBS.B01 数据库批量插入数据时,应使用事务机制进行批量提交
- openUBMC使用的sqlite数据库基于B+树结构存储数据,每次插入/删除一条数据时,都可能涉及周边数据节点和上级索引节点的调整,频繁的结构调整导致写入量远大于数据量,实测插入200条数据,数据总大小300KB,写入量会放大到20M。
- 当批量写入数据库操作达到5条及以上时,应该使用事务机制进行批量提交。
- 事务机制由框架
db:tx回调函数实现,db:tx使用sqlite的事务机制,将回调中的所有数据库操作作为一个事务处理, 具体实现为:先执行BEGIN,再执行回调函数,如果回调中的数据库操作执行成功,则执行COMMIT将修改写入数据库,如果执行失败则执行ROLLBACK回滚操作。 db:tx()方法在操作失败时会抛错,建议使用pcall调用。- 批量操作中如果有一个操作失败会回滚全部操作,建议将相关性强的操作放到一起。
【反例】
local_db:delete(local_db['Power']):exec()
for _, v in pairs(power_table) do
local power_row_data = local_db['Power'](saved_data)
power_row_data:save() -- Bad, 在循环中进行数据库插入
end
【正例】
local ok, err = pcall(local_db.db.tx, local_db.db, function ()
local_db:delete(local_db.Power):exec()
for _, v in pairs(power_table) do
local_db.Power(v):save() -- Good, 将数据库插入放在事务中执行
end
end)G.DBS.B02 避免使用枚举类型的属性作为持久化数据库表的主键
- 当枚举类型的属性作为主键时,扩展场景下新增枚举值,当升级新版本并且数据库表中添加了包含新增枚举值的记录,再回退到旧版本,持久化数据加载并校验时会由于找不到新增的枚举值而失败,从而引发兼容性问题
- 建议根据主键实际的数据类型进行定义即可,具有可扩展性和兼容性
【反例】
"properties": {
"Id": {
"baseType": "Enum",
"$ref": "types.json#/defs/EnumType",
"primaryKey": true
},
...
}
【正例】
"properties": {
"Id": {
"baseType": "U8",
"primaryKey": true
},
...
}G.DBS.B03 对于自建数据库,需要有可靠性修复机制,避免业务功能受损
- 此处的自建数据库是指组件内部通过sqlite库指定路径创建的数据库,不同于通过MDS定义由框架创建的数据库
- 数据库完整性校验可通过sqlite命令"PRAGMA quick_check"
/* 示例 */
#define SQLITE3_PATH "/usr/sbin/sqlite3"
/* DEST_DB_PATH为被校验的数据库路径 */
FILE *ptr = popen(SQLITE3_PATH " " DEST_DB_PATH " \"PRAGMA integrity_check\"", "r");
if (ptr == NULL) {
debug_log(DLOG_ERROR, "[%s] excute integrity check failed", __FUNCTION__);
return ret;
}
(void)vos_fgets_s(buf, sizeof(buf) - 1, ptr);
if (strncasecmp(buf, "ok", strlen("ok")) == 0) { // 返回ok表示成功,否则都是有问题
ret = RET_OK;
} else {
debug_log(DLOG_ERROR, "[%s] check failed, buf = %s", __FUNCTION__, buf);
}
pclose(ptr);
return ret;- 数据库修复可通过sqlite命令".dump"先导出历史命令,然后在新创建数据库文件中执行.dump导出的历史sql命令
/* 示例 */
#define SQLITE3_PATH "/usr/sbin/sqlite3"
// 导出数据库内容,DEST_DB_PATH为被修复的数据库路径,TEMP_SQL_PATH为导出的历史命令
gint32 ret = snprintf_s(cmd_str, sizeof(cmd_str), sizeof(cmd_str) - 1, "%s %s .dump > %s",
SQLITE3_PATH, DEST_DB_PATH, TEMP_SQL_PATH);
argv[0] = "/bin/sh";
argv[1] = "-c";
argv[2] = cmd_str;
argv[3] = NULL;
ret = vos_system_s(argv[0], argv);
// 重建一个数据库,TEMP_DB_PATH为重建的临时数据库
ret = snprintf_s(cmd_str, sizeof(cmd_str), sizeof(cmd_str) - 1, "%s %s < %s",
SQLITE3_PATH, TEMP_DB_PATH, TEMP_SQL_PATH);
ret = vos_system_s(argv[0], argv);
// 用重建的数据库替换原来的数据库
(void)vos_file_copy(DEST_DB_PATH, TEMP_DB_PATH);3.6 其他
P.05 禁止使用对象名处理业务流程
- 包括但不限于对象名称比对、对象名分段解析等处理
- 对象名及命名规则无法保证不发生变化,不能作为资源协作接口之间的约束
【反例】
-- 截取position后两位做为slot,若sr配置变化,则可能出现功能问题。Slot属性改为在sr配置即可
object.Slot = tonumber(string.sub(position, -2))P.06 合理设计调试打印、日志代码
- 代码中的调试、日志部分,作为问题定位辅助手段,应尽量少,不可以喧宾夺主
- 一个模块尽量只在出入口或异常时记录调试、日志信息,其中间行为过程尽量通过白盒测试保证可靠性
G.OTH.B01 日志打印满足openUBMC日志规范要求
Error:程序运行过程中的错误信息,当前处理必须中止,并向上抛出错误,比如无法连接数据库,解析JSON字符串失败、文件创建失败
Warning:程序运行过程中有负面影响的信息或事件,当前处理还可以继续,但是可能会有更严重的故障发生,比如内存占用率高于指定阈值,某处理过程超过了预期耗时,flash写入量超标等
Notice:程序运行过程中的重要信息或事件,对程序运行没有负面影响,比如收到了某个关键的系统信号/事件,定时任务成功的完成执行、程序开始监听某个端口等
Info:程序运行过程中的一般信息或事件,重要性比Notice级别低,比如某个任务的某个部分完成执行
Debug:程序运行过程的详细信息,比如报文数据,函数调用轨迹(入口、出口)等G.OTH.B02 使用mc.logging库中格式化日志输出函数,格式化符'%d'和'%u'的输出值必须为整型值
local log = require 'mc.logging'
local c = 5 / 2
local d = 5 // 2
log:info('c = %d', c) -- Bad, c = 2.5 程序会抛异常
log:info('d = %d', d) -- Good, d = 2G.OTH.B03 推荐以%s对number类型的变量进行打印,可避免该变量在异常情况下为nil时导致的程序抛错
【示例】
log:error('fail count = %s', a) -- a为number类型,即使为nil也能正常运行G.OTH.B04 禁止使用系统时间来计算时间间隔
- 系统时间可能发生跳变(例如从NTP服务器同步时间),会导致时间间隔计算不准确。系统时间只能用于时刻记录。常用获取系统时间的函数如下:
| 名称 | 简要说明 |
|---|---|
| vos.vos_get_cur_time_stamp() | 对C函数time(0)的封装,单位s |
| os.time() | 没有入参时,返回系统当前时间 |
| utils.time_ms() | 对C函数clock_gettime的封装,时钟类型为CLOCK_REALTIME |
- 运行时间不会发生跳变,可以用来计算时间间隔。常用获取运行时间的函数如下:
| 名称 | 简要说明 |
|---|---|
| vos.vos_tick_get() | 基于jiffies计算系统运行时间,单位ms |
| skynet.now() | 返回当前进程的运行时间,单位0.01s |
G.OTH.B05 调用C库返回的字符串使用lua_pushlstring替代lua_pushstring,可避免包含0x00的字符串被截断
lua_pushstring:将指针 s 指向的零结尾的字符串压栈lua_pushlstring:将指针 s 指向的长度为len的字符串压栈,字符串内可以是任意二进制数据,包括零字符
【反例】
char *s;
// 省略部分代码
lua_pushstring(L, s);
【正例】
char *s;
// 省略部分代码
lua_pushlstring(L, s, len); // len为数据长度G.OTH.B06 禁止使用0XXX表示8进制数
- Lua不支持像C语言的方式表示8进制数
【反例】
utils.chmod(file_path, 0640)
【正例】
utils.chmod(file_path, utils.S_IRUSR | utils.S_IWUSR | utils.S_IRGRP) -- 0640权限G.OTH.B07 禁止对有热插拔场景的csr对象使用单例模式
- 对有热插拔场景的csr对象使用单例模式,当该对象被卸载之后再次分发时,由于单例对象已被创建过则不会再次创建,此时单例对象里的参数仍为上一次的脏数据,会导致业务代码出现异常。
G.OTH.B08 使用skynet.queue队列进行处理时应考虑范围是否合理,避免使用全局队列
- 使用skynet.queue队列控制任务顺序执行时,应注意队列包含的任务范围应尽量小,避免使用全局队列,队列混用时容易导致阻塞时间过长,引发性能问题
【反例】
local queue = require 'skynet.queue'
local cs = queue() -- 全局队列
local s = class()
function s:fun1()
cs(funa, ...)
end
function s:fun2()
cs(funb, ...) -- funa和funb共用全局队列,funa的执行也会对funb产生影响;不同的s实例对象之间的funa与funb执行也会互相影响;队列混用容易导致阻塞时间长,引发性能问题
end
【正例】
local queue = require 'skynet.queue'
local s = class()
function s:ctor()
self.cs_a = queue()
self.cs_b = queue() -- 不同对象以及不同函数的队列分开声明,避免互相影响
end
function s:fun1()
self.cs_a(funa, ...)
end
function s:fun2()
self.cs_b(funb, ...)
endG.OTH.B09 使用sed命令禁止带有硬编码行号
- 原文件行号一旦发生变化,可能导致修改内容错位
- 对于文件内容的修改应当通过查找或者插入文件首/文件尾实现
【反例】
self.run("sed -i '12 i\ chown root:root /dev/shm/dbus/.dbus' rundbus.sh") -- Bad,通过行号查找
【正例】
self.run("sed -i '$ i\chown root:root /dev/shm/dbus/.dbus' rundbus.sh") -- Good,基于行尾查找G.OTH.B10 跨skynet服务调用若需要获取执行结果应使用skynet.call而非skynet.send
- skynet.call为同步阻塞式调用,执行skynet.call能获取到执行情况及返回值结果
- skynet.send为异步非阻塞式调用,无论目标服务是否存在或是否能执行成功该调用都能执行成功
【反例】
local ok, ret = pcall(function ()
return skynet.send('main', 'lua', 'exec_function', parameter) -- Bad,无论是否存在main服务或exec_function是否执行成功,pcall返回结果恒为true
end)
【正例】
local ok, ret = pcall(function ()
return skynet.call('main', 'lua', 'exec_function', parameter) -- Good,当main服务不存在或exec_function执行异常,pcall能正常捕获,ret也能正常接收到执行返回值
end)4 安全编码
概念解释
【提权】:安全常用术语,一个用户能通过某种非预期的方式,突破该用户的权限设计,完成正常情况下无法完成的某些操作,就称为提权。
【外部输入】:来自进程之外的数据就称为外部输入,包括:
- 文件(包括data分区的程序配置文件);
- 注册表;
- 网络报文和北向接口;
- 环境变量;
- 命令行输入;
- 用户态数据(对于内核程序而言);
- 进程间通信(包括管道、消息队列、socket、RPC等);
- 函数参数(对于全局API而言);
- 来自硬件的数据(如芯片寄存器,PMBus/SMBus/MCTP接收的数据等)。
【外部可控】:安全常用术语,是指能够被外部用户控制其数据内容的外部输入。外部可控分成以下两种,我们在编码时都需要考虑:
- 直接外部可控:数据本身是直接来自外部用户的某种输入,例如来自接口调用、网络报文等;
- 间接外部可控:外部用户无法直接控制数据内容,但是通过利用其他安全漏洞之后,就能够间接修改数据内容,这种称为间接外部可控。例如
/etc/passwd文件内容不是外部用户可以直接输入的,但是当软件存在任意文件写漏洞时,攻击者就可以控制/etc/passwd文件的内容,因此间接外部可控也存在潜在的风险。
【skynet】:一种轻量级开源服务端框架,当前openUBMC主要基于skynet框架进行组件app开发。
4.1 公共原则
P.SEC.B01 禁止组件自行封装私有安全检查函数
【规则说明】
安全检查函数,如检查文件路径、检查shell命令注入字符等,具有一定的专业性,组件自己封装,容易出现场景遗漏等问题,也不利于长期维护。因此各组件应该优先使用框架提供的公共安全检查函数(详见G.SEC.FIL.B02),如果识别到框架能力不满足,则应向社区反馈需求。
4.2 注入缺陷
注入缺陷包括很多种类型,包括命令注入、代码注入、SQL注入、日志注入等,对系统安全的威胁非常大,可能造成任意指令执行、提权、信息泄露等。其漏洞原理是很多文本型语言的控制字符和数据字符混合在一起,并且控制字符成对出现,攻击者如果能够输入任意字符,他就能够输入控制字符闭合开发者的控制字符,再插入攻击者的语法注入恶意语句。
G.SEC.INJ.B01 禁止使用os.execute、io.popen函数
【规则说明】
使用os.execute、io.popen函数执行shell命令会出现概率性进程卡死的问题,因此openUBMC禁止使用这两个函数。在需要执行某个shell命令的场景,优先看是否可以直接使用C库函数(例如调用C库chmod函数来替代Linux chmod命令)来实现,如果不能,应该使用vos.system_s函数来执行shell命令。
【案例一】
-- 错误示例,使用os.execute可能导致进程卡死
os.execute('cp /tmp/config.json /dev/shm/config.json')
-- 正确示例,使用vos.system_s执行shell命令
vos.system_s('/bin/cp', '/tmp/config.json', '/dev/shm/config.json')G.SEC.INJ.B02 以/bin/sh或者/bin/bash作为命令解释器时,需要对vos.system_s函数进行命令注入参数检查
【规则说明】
vos.system_s是openUBMC封装的供Lua程序调用的C函数库。如果该函数指定的命令解释器(即函数的第一个参数)是/bin/sh或者/bin/bash则存在命令注入风险,则需要对命令参数进行命令注入检查,框架已经提供公共检查函数check_shell_special_character_s。
【例外项】
如果vos.system_s函数的参数均为硬编码,不包括可能是外部输入的内容,则可以不校验。
【案例一】
-- 错误示例:
vos.system_s('/bin/sh', '-c', 'cp', src_dir, '/tmp/test') -- 假设src_dir是外部输入
-- 正确示例: 不使用/bin/sh作为命令解释器
vos.system_s('/bin/cp', src_dir, 'dest_dir') -- 假设src_dir是外部输入
-- 正确示例:使用/bin/sh作为命令解释器,但是有对参数进行校验
local utils_file = require 'utils.file'
if utils_file.check_shell_special_character_s(src_dir) ~= 0 then
return
end
vos.system_s('/bin/sh', '-c', 'cp', src_dir, '/tmp/test') -- 假设src_dir是外部输入G.SEC.INJ.B03 执行shell命令时,查找、匹配等操作条件要精准
【规则说明】
执行shell命令时,对文件、进程、用户等的查找,匹配等操作要按照实际查找对象的类型进行查找,防止存在进程仿冒、用户仿冒等情况出现。
// 错误示例:原始代码, 在满足进程信息包含sshd的情况下,可以构造特殊输入(例如传入'00'),从而杀掉其他sshd进程
snprintf_s(cmd_str, MAX_CMD_LENGTH, MAX_CMD_LENGTH - 1,
"ps -ef | grep -w \"%s\" | grep -w sshd | grep -vw \"/usr/sbin/sshd\" | \
cut -c 10-15 | xargs kill -9 > /dev/null 2>&1", escaped_user);
// 正确示例:按用户名查找
snprintf_s(cmd_str, MAX_CMD_LENGTH, MAX_CMD_LENGTH - 1,
"ps -ef | grep -w sshd |awk '{print $2,$9}'|grep -w \'%s\' |awk '{print $1}'| \
xargs kill -9 > /dev/null 2>&1", escaped_user);G.SEC.INJ.B04 外部输入作为require、load、loadfile、dofile、loadstring等函数参数时,需要进行白名单校验
【规则说明】
require、load、loadfile、dofile、loadstring等函数可以执行指定路径代码文件或指定字符串的代码块,存在代码注入风险,严禁将可能包含外部输入的数据直接作为这些函数的参数。如果必须使用,应该对参数进行白名单校验,确保执行内容安全可控。以下是Lua语言中存在代码注入风险的危险函数介绍。
require
- 原型:require([modulename])
- 解释:用于加载一个模块代码,如果代码文件是一个代码块,则会执行该代码库。require会搜索目录加载文件,并且会判断是否文件已经加载避免重复加载同一文件 。
load
- 原型:load (chunk [, chunkname [, mode [, env]]])
- 解释:load实现Lua 的反射机制,加载一个代码块,如果chunk是一个字符串,代码块就是这个字符串,chunk也可以是一个函数,通过函数调用获取代码块。如果chunk是一个字符串,且外部可控的话则存在安全风险,攻击者可以通过控制chunk导致一个代码注入。
loadfile
- 原型:loadfile ([filename [, mode [, env]]])
- 解释:通load相似,只是代码块是从文件中获取,如果文件内容可以外部控制,仍然存在代码注入风险。
dofile
- 原型:dofile([filename])
- 解释:加载文件中的代码,如果filename可控,则可以通过新增一个Lua脚本,将filename指定到新增脚本实现代码注入,如果文件内容可控则可以直接修改文件内容,导致代码注入问题。
loadstring
- 原型:loadstring(string [,chunkname])
- 解释:函数会从所给的字符串中来加载程序块并运行,如果省略参数`chunkname`,那么它默认为所给的字符串。G.SEC.INJ.B05 执行自定义SQL语句需要进行预编译
【规则说明】
某些复杂数据库操作,无法通过libmc封装的简单接口,如select、update、insert来完成,需要使exec函数执行自定义的SQL语句。如果这些自定义的SQL拼接了外部输入,则存在SQL注入的风险,预编译可以有效消除此风险。
【例外项】
如果自定义SQL语句是硬编码,不存在外部输入的内容,则可以不进行预编译。
-- 错误示例:自定义SQL语句未进行预编译,存在SQL注入风险
local cmd = 'select * from tb_test where level = ' .. level -- 假设level是外部输入
db:exec(cmd)
-- 正确示例
local cmd = 'select * from tb_test where level = ?'
local vm = db:prepare(cmd) -- 通过框架封装的prepare函数执行预编译
vm:bind_values(level)
vm:step()
vm:finalize()G.SEC.INJ.B06 在通过字符串拼装Lua脚本进行执行的场景,需要在check_shell_special_character_s的检查后根据脚本场景额外校验特殊字符
【规则说明】
在如worker机制中通过字符串拼装Lua脚本进行执行的场景,还需根据该脚本场景再排除包含可能提前闭环内层函数并注入命令的字符,如“'”、“)”、“ ”(Lua语法中使用空格即可作为命令分隔符,无需使用换行或“;”、“)”)
G.SEC.INJ.B07 skynet.sleep函数入参必须是整数
【规则说明】
skynet.sleep函数入参必须是整数,如果传入浮点数,会抛出异常
-- 假设变量ms的单位是毫秒,则:
-- 正确示例:
skynet.sleep(ms // 10)
-- 错误示例:
skynet.sleep(ms / 10)G.SEC.INJ.B08 使用框架提供的worker模块做线程操作时,禁止使用string.format方式在代码中拼接参数
【规则说明】
使用框架提供的worker模块(local worker = require ‘worker.core’)做线程操作时,禁止使用string.format方式在代码中拼接参数
【反例】
-- 正常new_path参数:/var/log/tmp.log。
-- 异常new_path参数:/var/log/tmp.log’);print('hello 异常场景下,会造成任意lua代码攻击
work:start(string.format([[
local file_sec = require 'utils.file'
file_sec.copy_file_s('%s', '%s')
]], tmp_path, new_path))
【正例】
work:start([[
local file_sec = require 'utils.file'
local tmp_path = worker:recv()
local new_path = worker:recv()
file_sec.copy_file_s(tmp_path, new_path)
]])
work:send(tmp_path, true)
work:send(new_path, true)4.3 文件操作
Lua提供的文件操作函数,和C语言一样,也存在通过软链接或../进行路径跨域。文件操作时未对不可信的路径进行标准化和校验或者校验不充分,导致路径遍历问题,被攻击者越权读写文件。
G.SEC.FIL.B01 创建文件或目录之后,需显式修改文件或目录的权限和属主
【规则说明】
遵循最小权限原则,及时修改文件权限和属主,避免因文件权限过大被低权限用户修改而存在安全风险。
G.SEC.FIL.B02 对于路径外部可控的文件进行任何操作前必须校验路径是否合法
【规则说明】
对于外部可控的文件路径,攻击者可以指定一个软链接路径或者通过../来实现路径跨越,从而造成任意文件的访问。为了防范这类风险,在文件的创建、删除、读写、复制、移动、修改权限或属主等操作之前,必须先进行路径标准化并校验路径是否在预期的目录下,否则容易造成提权、任意文件读写、任意文件删除、信息泄露等安全风险。
针对不同场景的文件路径校验需求,openUBMC封装了C语言和Lua语言版本的文件安全操作库。
// C语言版本
// 文件安全打开,返回文件描述符,打开文件前会校验文件是否是绝对化路径,及是否存在软链接,是否在指定路径
gint32 open_s(const gchar *pathname, guint32 flags, mode_t mode, const gchar *path_header);
// 文件安全打开,返回文件对象指针,打开文件前会校验文件是否是绝对路径,及是否存在软链接,是否在指定路径下
FILE *fopen_s(const gchar *path, const gchar *mode, const gchar *path_header);
// 文件关闭接口,与open_s配套使用
gint fclose_s(FILE *fp);
// 文件关闭接口,与fopen_s配套使用
gint close_s(gint32 fd);
// 判断文件路径是否有效,是否在指定路径下,一般用于文件导入场景
gint32 check_real_path_s(const gchar *file_path, const gchar *path_header);
// 在文件打开前,对文件路径的校验,判断文件路径是否是绝对路径以及是否存在软链接,是否在指定路径下,适用于文件导出场景
gint32 check_realpath_before_open_s(const gchar *file_path, const gchar *path_header);
// 适用于文件已经打开后,对文件操作前路径的一致性校验,判断文件路径是否是绝对化路径以及是否存在软链接,是否在指定路径下
gint32 check_realpath_after_open_s(const gint32 fd,
const gchar *file_path, const gchar *path_header);
// 支持文件移动(源文件移动到目标文件,保留源文件的内容和属性,源文件删除),仅包括文件内容,打开文件进行路径和软链接检查
gint32 move_file_s(const gchar *src_path, const gchar *dest_path);
// 支持文件完整拷贝接口,包括文件内容、文件属性(属主、创建时间、修改事件、访问时间等),打开文件进行路径和软链接检查
gint32 copy_file_s(const gchar *src_path, const gchar *dest_path);
// 支持文件拷贝接口,仅包括文件内容,打开文件进行路径和软链接检查
gint32 copy_file_content_s(const gchar *src_path, const gchar *dest_path);
// 支持文件读接口,打开文件需要进行路径和软链接检查
gint32 read_file_s(const gchar *file_name, void *buf, size_t size);
// 支持文件读接口,打开文件需要进行路径和软链接检查
gint32 write_file_s(const gchar *file_name, const void *buf, size_t size);
// access函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int access_s(const char *pathname, int mode);
// chdir函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int chdir_s(const char *path);
// opendir函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
DIR *opendir_s(const char *name);
// open函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
gint32 open_s(const gchar *pathname, guint32 flags, mode_t mode, const gchar *path_header);
// pathconf函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
long pathconf_s(const char *path, int name);
// stat函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int stat_s(const char *pathname, struct stat *statbuf);
// chmod函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int chmod_s(const char *pathname, mode_t mode);
// chown函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int chown_s(const char *pathname, uid_t owner, gid_t group);
// execl函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int execl_s(const char *pathname, const char *arg, ...);
// execlp函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int execlp_s(const char *file, const char *arg, ...);
// execle函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int execle_s(const char *pathname, const char *arg, ...);
// execv函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int execv_s(const char *pathname, char *const argv[]);
// execvp函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int execvp_s(const char *file, char *const argv[]);
// link函数的安全版本,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,检验失败返回-1
int link_s(const char *oldpath, const char *newpath);-- Lua语言版本
-- 文件安全打开,返回文件描述符,打开文件前会校验文件是否是绝对化路径,及是否存在软链接,是否在指定路径下。本函数返回的对象不支持seek方法,在需要使用seek方法指定写入点的特殊场景下,不能使用open_s替代原始的open函数,而是应该在open函数打开前使用check_realpath_before_open_s校验路径
open_s(filename, mode [, path_header])
-- 判断文件路径是否有效,是否在指定路径下,一般用于文件导入的场景
check_real_path_s(file_path [, path_header])
-- 在文件打开前,对文件路径的校验,判断文件路径是否是绝对路径以及是否存在软链接,是否在指定路径下,适用于文件导出场景
check_realpath_before_open_s(file_path [, path_header])
-- 适用于文件已经打开后,对文件操作前路径的一致性校验,判断文件路径是否是绝对化路径以及是否存在软链接,是否在指定路径下
check_realpath_after_open_s([file], file_path [, path_header])
-- 支持文件移动(源文件移动到目标文件,保留源文件的内容和属性,源文件删除),仅包括文件内容,打开文件进行路径和软链接检查
move_file_s(src_path, dest_path)
-- 支持文件完整拷贝接口,包括文件内容、文件属性(属主、创建时间、修改事件、访问时间等),打开文件进行路径和软链接检查
copy_file_s(src_path, dest_path)
-- 支持文件拷贝接口,仅包括文件内容,打开文件进行路径和软链接检查
copy_file_content_s(src_path, dest_path)
-- 支持文件读接口,读取指定字节数据,打开文件需要进行路径和软链接检查
read_file_s(file_name, size)
-- 支持文件写接口,写入指定数据,打开文件需要进行路径和软链接检查
write_file_s(file_name, data)
-- os.execute的安全版本,用于防命令注入执行shell命令,执行前会校验外部输入的字符串是否包含可能导致shell命令注入的特殊字符,检验失败会抛出错误,特殊字符包含"||", ";", "&&", "$", "|", "&", ">>", ">", "<", "`", "\", "!", "\n"
execute_s(cmd)
-- vos.system_s的安全版本,用于执行shell命令,执行前会校验外部输入的字符串是否包含可能导致shell命令注入的特殊字符,检验失败会抛出错误,特殊字符包含"||", ";", "&&", "$", "|", "&", ">>", ">", "<", "`", "\", "!", "\n"
check_before_system_s(cmd_path [, cmdstring])
-- chmod的安全版本,用于修改文件权限,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,校验失败会返回-1
chmod_s(path, fd_mode)
-- chown的安全版本,用于修改文件的属主和属组,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,校验失败会返回-1
chown_s(path, owner, group)
-- chdir的安全版本,用于切换目录,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,校验失败会抛出错误
chdir_s(dir_path)
-- stat的安全版本,用于显示文件或文件系统的详细信息,执行前会判断文件路径是否是绝对化路径以及是否存在软链接,校验失败会抛出错误
stat_s(path)【案例一】
-- 错误示例:
function export_event(list, path)
-- 省略部分代码
utils.remove(path) -- 错误,未对path进行校验
end
-- 正确示例:
function export_event(list, path)
if check_realpath_before_open_s(path, '/tmp') ~= 0 then
return -- path是非预期路径,不能直接删除
end
utils.remove(path) -- 正确,已对path进行校验
-- 省略部分代码
endG.SEC.FIL.B03 解压缩包操作需要进行完备的校验,防止zip炸弹
【规则说明】
解压前需要判断:
- 压缩包文件路径是否为软链接;
- 压缩包文件大小;
- 压缩包内被压缩文件的总数;
- 解压后的文件总大小;
- 压缩包内压缩文件名是否包含
../(如果使用unzip命令解压,则无需校验../); - 解压得到的文件中是否包括软链接。
框架已提供包含上述检查的安全解压接口,在解压操作时使用对应接口进行操作;
secure_tar_unzip: 对tar压缩文件格式的压缩包进行解压unzip_s: 对zip压缩文件格式的压缩包进行解压
【案例一】
-- 解压tar格式文件
-- 函数声明:secure_tar_unzip(file_path, dest_path, max_unzip_file_size, max_file_num)
local utils = require 'mc.utils'
local ok = utils.secure_tar_unzip(file_path, dest_path, max_unzip_file_size, max_file_num)
if not ok then
log:error('secure_tar_unzip failed')
end【案例二】
-- 解压zip格式文件
-- 函数声明:unzip_s(file_path, dest_path, max_unzip_file_size, max_file_num)
local utils = require 'mc.utils'
local ok = utils.unzip_s(file_path, dest_path, max_unzip_file_size, max_file_num)
if not ok then
log:error('unzip_s failed')
endG.SEC.FIL.B04 往同一路径下解压文件,需要做重入判断,禁止允许并发解压
【规则说明】
如果允许同一个路径下并发解压,则可以构造两个特殊的压缩包,利用解压过程中残留的文件,进行任意文件写,可以导致沙箱被破解。
G.SEC.FIL.B05 使用外部导入的文件之前,需判断文件属主是否与执行导入操作的用户一致
【规则说明】
安全要求,导入之前先判断文件属主是否与触发导入操作的用户一致,可以避免高权限用户上传的文件被低权限用户使用。
【案例一】
local function check_owner(ctx, file_path)
local uid = get_uid_gid_by_name(ctx) -- 通过上下文获取当前用户的uid
local file_owner = utils_core.stat_s(file_path).st_uid
if file_owner ~= uid then
log:error('file owner(%d) is not match current user(%d)', file_owner, uid)
utils.remove_file(file_path)
return false
end
return true
endG.SEC.FIL.B06 高权限用户严禁执行低权限用户可修改的shell脚本
【规则说明】
如果高权限用户执行低权限用户具备写权限的脚本文件,那么低权限用户只要修改脚本内容,就可以在高权限用户执行该脚本的时候,借助高权限用户来执行恶意命令。具体来说,包括以下两种场景:
- 直接使用:高权限用户直接执行低权限用户可修改的脚本文件;
- 间接使用:高权限用户执行的是低权限用户不可修改的脚本文件,但是该脚本里面会执行某些低权限用户可修改的脚本文件。
G.SEC.FIL.B07 不要在共享目录中创建和存放临时文件
【规则说明】
共享目录是指其它非特权用户可以访问的目录。程序的临时文件应当是程序自身独享的,任何将自身临时文件置于共享目录的做法,将导致其他共享用户获得该程序的额外信息,产生信息泄露。因此,不要在任何共享目录创建仅由程序自身使用的临时文件。
临时文件通常用于辅助保存不能驻留在内存中的数据或存储临时的数据,也可用作进程间通信的一种手段(通过文件系统传输数据)。例如,一个进程在共享目录中创建一个临时文件,该文件名可能使用了众所周知的名称或者一个临时的名称,然后就可以通过该文件在进程间共享信息。这种通过在共享目录中创建临时文件的方法实现进程间共享的做法很危险,因为共享目录中的这些文件很容易被攻击者劫持或操纵。
临时文件(例如升级包、证书文件等)必须先拷贝到非共享目录(例如从/tmp目录拷贝到/data或者/dev/shm下)再进行校验 。
G.SEC.FIL.B08 建议将远程文件下载到本地目录后再进行处理
【规则说明】
部分远程文件通过将目录挂载后直接在挂载目录上进行操作,此时远程服务器不可控,攻击者可在完成文件校验后竞争替换远程服务器上的文件实现绕过。
4.4 外部输入
使用外部输入数据时,处理上面指出的命令注入、代码注入、路径跨越风险外,还需要注意以下问题。
G.SEC.EXT.B01 禁止外部输入直接作为循环上限
【规则说明】
以外部输入作为循环上限时,可能会由于范围过大而索引不到结果,引发异常。
G.SEC.EXT.B02 禁止外部输入直接作为除数
【规则说明】
Lua同C语言要求,除法或求余操作中,一旦发生除0的情况,将产生异常。
G.SEC.EXT.B03 外部输入作为表索引时,对索引结果要判空后再使用
【规则说明】
以外部输入作为表索引或者数组下标时,需要对索引结果判空再使用,否则如果对得到的nil值进行操作,可能引发异常。
【案例一】
-- 错误示例
-- 根据cmd_type加载具体的消息处理类
function func(type)
-- 错误,应该对handlers[cmd_type]判空后再使用,否则传入一个不存在的索引值,会触发程序断言失败
self.handler_func = handlers[type].func
end
-- 正确示例
function func(type)
local handler = handlers[type]
if not handler then
return
end
self.handler_func = handler.func
endG.SEC.EXT.B04 公共函数中禁止日志打印可能是外部输入的参数取值
【规则说明】
公共函数打印外部输入参数,容易造成信息泄露风险,且代码排查时难以识别。例如,一个检验文件路径是否合法的公共函数中,如果打印了此路径;那么调用者不小心将包含密码的远程文件路径传入了,就会导致敏感信息泄露。
特别要注意一些公共机制回调函数,如配置导入回调不要无条件打印参数值;以及一些开源软件日志配置中,要注意不能打印可能是敏感信息的部分。
G.SEC.EXT.B05 对于外部输入进行直接校验,不要对二次计算后的结果进行间接校验
【规则说明】
对外部输入的运算结果进行校验容易出现整数翻转或整数溢出截断,造成安全风险,因此应当对外部输入本身进行直接校验。
【案例一】
data_cnt是外部消息控制的,在校验报文长度时没有直接校验,而是对一个乘法和加法运算结果进行校验,这就导致攻击者通过构造特殊的data_cnt取值,使得计算结果data_len超过unsigned int的最大范围,出现整数溢出截断成一个较小的数,从而使下面的长度校验能够通过,但是实际使用data_cnt时又会出现越界读写。
int HandleMsg(unsigned int data_cnt, unsigned int msg_len)
{
unsigned int data_len = sizeof(MSG_INFO_S) + data_cnt * sizeof(Data_S);
// 校验消息长度是否满足要求
if (data_len > msg_len) {
return -1;
}
for (int i = 0; i < data_cnt; i++) {
...... // 省略部分代码
}
}4.5 敏感信息
openUBMC涉及的敏感信息包括但不限于:openUBMC用户密码、会话token、包含密码的远程文件传输URL、包含密码的虚拟媒体挂载地址、SMTP登录密码、SNMP团体名、SNMP加密密码、SP升级的ImageURI和SignalURI、SP系统部署配置中的CDKey和RootPwd、NTP组秘钥、KerberOs密钥表、证书私钥、证书加密密码、加密密钥(包括根密钥、主密钥和工作密钥)、Redfish事件订阅请求头、VNC密码、BIOS密码、LDAP绑定密码(BindDNPsw)、SSH Host key。
G.SEC.SEN.B01 敏感信息明文禁止泄露
【规则说明】
敏感信息泄露行为包括:打印到日志、在接口响应体或错误消息中明文返回、在UI界面上(包括命令行和Web)明文显示、明文存储到文件系统(含内存文件系统)、明文存储到数据库。
G.SEC.SEN.B02 北向接口映射器配置中,对于可能是敏感信息的请求参数,需要增加Sensitive标识
【规则说明】
为了避免在cli命令或者接口错误响应消息中回显用户输入的敏感信息,需要在映射器配置中增加Sensitive标识。增加该标识后,可以自动隐藏敏感信息具体值。
【案例一】
{
"Type": "PATCH",
"ReqBody": [
{
"Name": "SenderPassword",
"Type": "string",
"Sensitive": true // 发送密码是敏感信息,需要在回显中隐藏具体值
}
]
}G.SEC.SEN.B03 后台组件错误引擎抛错时,禁止在message中返回敏感信息
【规则说明】
后台组件在处理错误引擎抛错时,如果参数值是敏感信息,则不能直接传入抛错函数,而应该使用6个*替代,避免在北向接口回显中打印出来。
【案例一】
-- 错误案例:
if url:len() > 255 then
error(base_messages.PropertyValueFormatError(url, '%Url')) -- 错误:url可能是敏感信息
end
-- 正确案例:
if url:len() > 255 then
error(base_messages.PropertyValueFormatError('******', '%Url')) -- 正确:隐藏了敏感信息
end4.6 资源泄露
G.SEC.LEA.B01 C库中须保证内存正确释放
【规则说明】
在封装给Lua调用的C/C++动态库中,使用C/C++库函数创建的资源,仍需跟正常C/C++程序一样,在使用完毕后,需要进行资源释放,否则存在资源泄露。
G.SEC.LEA.B02 注意正确调用开源软件的资源申请和释放函数
- 通过Lua重新封装的开源软件API,在调用时同样要注意资源的申请和释放
- 比如,若调用了某个封装了SSL_CTX_new的Lua函数,则必须相应地调用封装了SSL_CTX_free的接口
4.7 其他
G.SEC.OTH.B01 禁止封装会调用到阻塞性API的C库给Lua代码直接调用
【规则说明】
C库中调用阻塞性API,如sleep、usleep、accept、recv、send等,直接在Lua代码中调用该C库函数会阻塞skynet的worker线程,导致该进程下所有skynet服务任务调度出现异常,进而导致相关资源协作接口访问阻塞、组件心跳丢失被框架重启等问题。推荐做法为:调用框架提供的worker库,创建一个worker线程,将阻塞操作在worker线程中执行
G.SEC.OTH.B02 禁止在Lua代码中直接调用执行时间较长的shell脚本
【规则说明】
在Lua代码中直接执行时间较长的shell脚本(如配置网络、压缩/解压缩大文件)也会阻塞skynet的worker线程,影响同G.SEC.OTH.B01。解决方法也是使用框架提供的worker机制来执行shell脚本。
G.SEC.OTH.B03 禁止在app启动函数中执行耗时较长或者耗时不确定的操作
【规则说明】
在app启动函数中执行耗时较长或者耗时不确定的操作(例如阻塞性等待其他组件上资源协作接口),可能导致启动超时,服务被框架重启。此类操作建议异步执行,不阻塞启动过程。
G.SEC.OTH.B04 创建skynet协程操作外部可控时,必须控制协程创建的最大数量
【规则说明】
同时创建过多的skynet协程,会导致app内存暴涨直至出现OOM(Out Of Memery),如果协程创建操作外部可控,则会造成DOS攻击,因此必须限制最大并发数量。
G.SEC.OTH.B05 如果某个外部可控的操作存在较多内存分配,则须保证及时GC
【规则说明】
某个外部可控的操作存在较多内存分配,例如查询BIOS配置、查询BIOS属性注册表、导入根证书等,可以考虑在操作结束后主动进行GC,避免因为Lua虚拟机自动GC不及时导致OOM。
添加collectgarbage调用会引起CPU占用升高,请在sig例会评审通过后再添加
G.SEC.OTH.B06 使用libcurl发起的请求,必须显式设置超时时间和连接超时时间,避免网络异常情况下线程阻塞
\* 示例 *\
CURL *curl = curl_easy_init();
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); // 连接超时时间10s
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); // 设置超时时间60s
curl_easy_perform(curl);5 异常处理
5.1 断言
G.AST.B01 正式代码禁止使用断言
【规则说明】
通常,断言函数用于评估边界条件并暴露问题,以便在代码中进行定位和修复。断言对于调试很有用。在大多数语言中,断言在代码的发布版本中都是关闭的。在Lua语言中没有明显的调试和发布模式,这意味着程序中的断言会导致错误。除非正确对错误进行处理,否则会出现程序硬退出。下面两个函数都会抛出异常:
error(message [, level]):打印出message后,会终止程序运行。assert(v [, messafe]):即v是假(nil或false)时,调用error函数,否则返回所有所有参数。其中message默认值是"assertion failed!"。
除以下例外场景,其他场合禁止使用error和assert函数:
- 当前错误引擎机制依赖
error函数,在错误引擎相关代码中可以使用error函数抛异常 - 集成测试或单元测试中允许使用error或assert函数
G.AST.B02 使用pcall或xpcall来调用可能抛异常的函数
【规则说明】
一些skynet库函数大量使用了assert断言,为了避免异常情况下程序退出,应该使用pcall或xpcall来调用这些库函数。例如skynet的socket库和websocket库,均大量使用assert断言。
function socket.read(id, sz)
local s = socket_pool[id]
assert(s)
...
end
function websocket.write(id, data, fmt, masking_key)
local ws_obj = assert(ws_pool[id])
fmt = fmt or "text"
assert(fmt == "text" or fmt == "binary")
write_frame(ws_obj, fmt, data, masking_key)
endpcall(f, arg1, arg2, ...):以一种"保护模式"来调用第一个参数,能够捕获执行中的任何错误。xpcall(f, msgh, arg1, arg2, ...):相比pcall增加了一个msgh参数,作为错误处理回调函数。
G.AST.B03 函数的异常处理不能混用return和抛异常两种方法
- 函数的异常处理要么使用return来返回异常状态;要么使用error错误引擎抛出错误。不可两种方法混用,容易导致接口使用错误。
6 SR配置
6.1 字符串操作
G.SR.STR.B01 string.format的参数不允许使用表达式
SR语法限制。当string.format的参数为表达式时,不返回表达式的结果
【反例】
"Name": "${Slot} |> string.format('Cpu%sChannel0', $1 * 2)" -- Bad, string.format的参数使用了表达式。若${Slot}为1,结果为"Cpu1 * 2Channel0"
【正例】
"Name": "${Slot} |> expr($1 * 2) |> string.format('Cpu%sChannel0', $1)" -- Good, 将表达式的结果作为string.format的参数。若${Slot}为1,结果为"Cpu2Channel0"