
2020 年 618 大促已經過去,作為淘系每年重要的大促活動,淘系前端在其中扮演着什麼樣的角色,如何保證大促的平穩進行?又在其中應用了哪些新技術?淘系前端團隊特此推出「618 系列|淘系前端技術分享」,為大家介紹 618 中的前端身影。
本篇來自于頻道與D2C智能團隊的菉竹,為大家介紹本次 618 大促中是如何用代碼掃描做資損防控的。
前言
現如今,日常業務的資損防控工作在安全生産環節中已經變得越來越重要。尤其是每逢大促活動(譬如本次 618 大促),一旦出現資損故障更容易引發重大損失。就目前來說,有效的防控手段一般有:
- 項目上線前 code review,通過預演提前發現問題
- 線上實時監控對賬,出現問題時執行預案,及時止血
由上可以看出,及時止血隻能減小資損規模,要想避免資損還得靠人工 code review 在項目上線之前發現問題。
然而,一方面 code review 需要額外的人工介入,且其品質參差不齊,無法得到保障;另一方面,高品質的 code review 也會花費較多時間,成本較高。
那麼有沒有一種兩全其美的方法:以一種低成本的方式,自動發現代碼中存在的資損風險,進而保障代碼品質?答案是:代碼掃描!
我們希望每次代碼送出時都能自動檢測出代碼中的資損風險并給出告警,進而在研發階段就能提前發現問題并及時修複。接下來,本文就将介紹本次 618 資損防控中我們是如何用 AST 來做靜态代碼掃描的。
什麼是AST
在上文中,我們提到可以利用 AST 來做靜态代碼掃描,檢測代碼中是否存在某些可能造成資損或者輿情的場景。那麼問題來了,AST 是什麼呢?
在計算機科學中,抽象文法樹(Abstract Syntax Tree,AST)或簡稱文法樹(Syntax tree),是源代碼文法結構的一種抽象表示。它以樹狀的形式表現程式設計語言的文法結構,樹上的每個節點都表示源代碼中的一種結構。
這是一段引自百科上的解釋,什麼意思呢?讓我們一起來看下面這個例子:
可以看到,非常簡單的一句初始化指派代碼 var str = "hello world" 被拆解成了多個部分,并用一棵樹的形式表示了出來。(如果想檢視更多源代碼對應的 AST,可以使用神器 astexplorer 線上嘗試)
其實,我們每天日常工作都在使用的 js 代碼編譯工具 — Babel,它也離不開 AST。為了将 ES6 甚至更高版本的 js 文法轉換成浏覽器相容性更好的 ES5 代碼,Babel 每次都需要先将源代碼解析成 AST,然後修改 AST 使其符合 ES5 文法,最後再重新生成代碼。總結一下就是3個階段:parse -> transform -> generate。
到這兒,也許你的心中會冒起一個想法:"咦?前文就在說可以用 AST 做代碼掃描,而 Babel 又恰好已經做了解析 AST 的工作,難道..." 沒錯,當你帶着這個疑惑打開 Babel官網 時,你會發現:真香~~~
Babel 不但完成了 AST 的解析工作,而且由于其編譯 js 代碼的使命,它還提供了一套完善的 visitor 插件機制用于擴充,而種種的這些都為我們的代碼掃描工作創造了完美的條件。
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// 編寫自定義規則插件
const visitor = {};
// 源代碼
const code = `var str = "hello world";`;
// code -> ast
const ast = parser.parse(code);
// 用自定義規則周遊ast(即代碼掃描)
traverse(ast, visitor);
如上所示,利用 Babel 提供的能力來做代碼掃描就是如此簡單,唯一要做的就是結合我們自定義的資損/輿情規則來編寫 Babel 開放的 visitor 插件。(有關"如何自定義 Babel 插件"可以檢視這份Babel插件手冊,該文檔介紹了如何編寫一個 Babel 自定義插件,也是後文的基礎 )
解決問題
在簡單介紹完 AST 後,讓我們回歸到本文的核心問題:面對以下這些可能發生資損故障的場景,什麼"千奇百怪"的代碼都可能出現,我們該如何做檢測呢?
- 前端金額賦預設值
- 前端金額計算錯誤
- 前端寫死固定金額/積分
- ...
▐ 尋找"金額"
根據上文的描述,我們知道 "金額" 在前端就是一個高危分子,有關它的操作都容易造成資損。
一方面,這是因為 js 的數字"精度"問題(老生常談的 "0.1 + 0.2 = 0.300000004" 問題);另一方面,金額計算本就應該放在服務端更安全。
是以,為了避免潛在的風險,所有的金額計算操作都應該由服務端計算後下發給前端,而前端隻做展示作用。這也正是代碼掃描的關鍵一步,我們需要檢測代碼中是否含有金額的計算操作。
為了找出代碼中的金額計算,首先要做的就是識别代碼中的 "金額變量"。對于這個問題,我們可以使用簡單粗暴卻又行之有效的方法:正則比對。由于大家的金額變量名取得都比較有規律(就比如 xxxPrice,PS:可繼續擴充),我們可以用一個簡單的正則進行比對:
const WHITE_LIST = ['price']; // TODO: 可擴充
const PRICE_REG = new RegExp(WHITE_LIST.map(s => s + '$').join('|'), 'i');
根據 Babel 解析得到的 AST,由于變量名均是 Identifier 類型的節點,是以我們可以用一個簡單的規則來比對所有的金額變量:
const isPrice = str => PRICE_REG.test(str);
const visitor = {
Identifier(path) {
const {id} = path.node;
if(isPrice(id.name)) {
// 金額變量 比對成功!
}
}
};
▐ 小試牛刀
解決金額變量的定位問題後,我們再來看看 "金額賦預設值" 的檢測問題。
// case 1: 直接賦預設值
const price = 10;
// case 2: ES6解構文法賦預設值
const {price = 10} = data;
// case 3: "||"運算符賦預設值
const price = data.price || 10;
// ...
如上所示,雖然金額賦預設值有多種寫法,但是當它們被解析成 AST 後,我們卻可以将其逐一擊破。說到這,就不得不再次祭出 astexplorer 神器将上述代碼分析一波。
case 1: 直接賦預設值
根據上面的 code vs AST 關系圖可以看到,我們隻要找到 VariableDeclarator 節點,且同時滿足 id 是金額變量,init 是大于 0 的數值節點這兩個條件即可。代碼如下:
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(
t.isIdentifer(id) &&
isPrice(id.name) &&
t.isNumericLiteral(init) &&
init.value > 0
) {
// 直接賦預設值 比對成功!
}
}
};
case 2: ES6解構文法賦預設值
經過對上一個 case 的解析,我們其實已經初步掌握了如何用 AST 做代碼掃描的要領,再來看 ES6解構文法賦預設值 的檢測。觀察上面的關系圖,我們可以得出結論:找到 AssignmentPattern 節點,且同時滿足 left 是金額變量,right 是大于 0 的數值節點這兩個條件。代碼如下:
const t = require('@babel/types');
const visitor = {
AssignmentPattern(path) {
const {left, right} = path.node;
if (
t.isIdentifer(left) &&
isPrice(left.name) &&
t.isNumericLiteral(right)
&& right.value > 0
) {
// ES6解構文法賦預設值 比對成功!
}
}
};
case 3: "||"運算符賦預設值
經過上面的兩個例子說明,想必 "||"運算符賦預設值 的檢測已經不在話下。不過這裡需要特别注意一點:在實際的代碼中,= 右側的指派表達式可能并不像例子中給的 "data.price || 10" 這般簡單,而是可能夾雜着一定的邏輯運算。對于這類情況,我們需要改變政策:周遊右側的指派表達式中是否包含 "|| 正數" 的模式。
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(t.isIdentifer(id) && isPrice(id.name)) {
path.traverse({
LogicalExpression(subPath) {
const {operator, right} = subPath.node;
if(
operator === '||' &&
t.isNumericLiteral(right) &&
right.value > 0
) {
// "||"運算符賦預設值 比對成功!
}
}
});
}
}
};
▐ 變量追蹤
根據上文的介紹,其實一些基礎規則的代碼掃描已經可以實作,然而現實中送出的代碼往往會比上面給出的例子複雜得多。就拿金額計算來說,我們可以用下面的 visitor 來比對任何有關金額的四則運算:
const t = require('@babel/types');
const Helper = {
isPriceCalc(priceNode, numNode, operator) {
return (
t.isisIdentifier(priceNode) &&
isPrice(priceNode.name) &&
(t.isNumericLiteral(numNode) || t.isIdentifier(numNode)) &&
['+', '-', '*', '/'].indexOf(operator) > -1
);
}
};
const checkPriceCalcVisitor = {
BinaryExpression(path) {
const {left, right, operator} = path.node;
if(
Helper.isPriceCalc(left, right, operator) ||
Helper.isPriceCalc(right, left, operator)
) {
// 金額計算 比對成功!
}
}
}
然而,上面的規則卻隻能檢測對金額變量的直接運算,一旦碰上函數調用就無效了。比如以下代碼:
const fen2yuan = (num) => {
return num / 100;
};
const ret = fen2yuan(data.price);
這是一個再簡單不過的分轉元金額機關換算函數,由于形參不具備金額變量名的特征,先前的規則将無法成功檢測。為了解決 "變量追蹤" 這個問題,我們還需引入 Babel 中的 Scope 能力。根據 官方文檔 介紹,一個 scope 可以被表示成:
// 一個scope
{
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [...]
}
// 其中的一個binding
{
identifier: node,
scope: scope,
path: path,
kind: 'var',
referenced: true,
references: 3,
referencePaths: [path, path, path],
constant: false,
constantViolations: [path]
}
有了上面這些資訊,我們就可以查找任何一個變量的聲明以及任何一個綁定的所有引用。什麼意思呢?
前文提到的變量追蹤問題在于:原本是金額變量名的實參在函數調用時,形參可能變成了和金額無關的變量名。但是現在,我們可以借助 scope 順藤摸瓜,先找到該函數的聲明,然後根據參數的位置資訊重建立立實參和形參之間的關系,最後再用 binding 檢測函數體内是否含有對形參的四則運算。
const t = require('@babel/types');
const Helper = {
// ...
findScope(path, matchFunc) {
let scope = path.scope;
while(scope && !matchFunc(scope)) {
scope = scope.parent;
}
return scope;
}
};
const checkPriceCalcVisitor = {
// ...
CallExpression(path) {
const {arguments, callee: {name}} = path.node;
// 比對金額變量作為實參的函數調用
const priceIdx = arguments.findIndex(arg => isPrice(arg));
if(priceIdx === -1) return;
// 尋找該函數的聲明節點
const foundFunc = Helper.findScope(path, scope => {
const binding = scope.bindings[name];
return binding && t.isFunctionDeclaration(binding.path.node);
});
if(!foundFunc) return;
// 比對實參和形參之間的位置關系
const funcPath = foundFunc.bindings[name].path;
const {params} = funcPath.node;
const param = params[priceIdx];
if(!t.isIdentifier(param)) return;
// 檢測函數内是否有對形參的引用
const renamedParam = param.name;
const {referencePaths: refPaths = []} = funcPath.scope.bindings[renamedParam] || {};
if(refPaths.length === 0) return;
// 檢測形參的引用部分是否涉及金額計算
for(const refPath of refPaths) {
// TODO: checkPriceCalcVisitor支援指定變量名的檢測
refPath.getStatementParent().traverse(checkPriceCalcVisitor);
}
}
}
如上所示,借助 scope 和 binding 的能力,我們就基本解決了 "變量追蹤" 問題。
檢測效果
經過前文對基本原理介紹後,我們再來看下實際的檢測效果。從代碼掃描上線之後到本次 618 活動目前為止,我們對一批前端代碼倉庫進行了掃描,共有 1/7 的倉庫都命中了規則。下面挑了幾個例子來感受下藏在代碼中的"毒藥"~
Bad code 1:
let {
// ...
rPrice = 1
} = res.data || {};
如上所示,當服務端傳回的資料異常時,一旦 res.data 為空,那麼 rPrice 就會獲得預設值 1。經過代碼分析後發現 rPrice 代表的就是紅包面額,是以理論上就可能會造成資損。
Bad code 2:
class CardItem extends Component {
static defaultProps = {
itemPrice: '99',
itemName: '...',
itemPic: '...',
// ...
}
// ...
}
如上所示,該代碼應該是在開發初期 mock 了展示所需的資料,但是在後續疊代時又沒有删除 mock 資料。一旦服務端下發的資料缺少 itemPrice 字段,所有的價格都将顯示 99,這也是顆危險的定時炸彈。
Bad code 3:
const [price, setPrice] = useState(50);
如上所示,這個 hooks 的使用例子預設就會給 price 指派 50,如果這是一個紅包或券的面額,意味着使用者可能就領到了這 50 元,進而也就造成了資損。
Bad code 4:
// price1為活動價,price2為原始價
let discount = Math.ceil(100 * (price1 / 1) / (price2 / 1)) / 10;
如上所示,這是一個前端計算折扣的代碼案例。按照前文提到的約定,凡是涉及到金額計算的邏輯都應該放在服務端,前端隻做展示邏輯。是以,如果能檢測出這類代碼,還是可以從源頭上避免不必要的風險。
Bad code 5:
Toast.show('恭喜您獲得雙11紅包');
如上所示,這是一段字元串常量中包含大促關鍵字(雙11)的代碼。由于目前是 618 大促,如果使用者看到這個 toast 提示就不合适了,雖然不會造成資損,但可能會引發輿情。是以,原則上來說,前端使用的兜底文案就應該是通用型文案,凡是此類帶"時效性"的文案要麼走配置下發,要麼服務端下發。
總結與展望
本文先對 AST 做了簡單介紹,接着圍繞資損防控問題介紹了如何用 AST 做代碼掃描的基本原理,最後再以實際倉庫的掃描結果驗證檢測效果。目前來看,針對一些通用型的問題,通過代碼掃描确實能夠發現一些藏在代碼中的潛在資損/輿情風險。但是對于一些和業務邏輯強相關的資損風險目前仍不容易檢測,這還需從其他角度點進行突破。
關注「淘系技術」微信公衆号,一個有溫度有内容的技術社群~