開發與維運

AST 代碼掃描實戰:如何保障代碼質量

滾動.gif

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),是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。

這是一段引自百科上的解釋,什麼意思呢?讓我們一起來看下面這個例子:

image.png

可以看到,非常簡單的一句初始化賦值代碼 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: 直接賦默認值

image.png

根據上面的 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解構語法賦默認值

image.png

經過對上一個 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: "||"運算符賦默認值

image.png

經過上面的兩個例子說明,想必 "||"運算符賦默認值 的檢測已經不在話下。不過這裡需要特別注意一點:在實際的代碼中,= 右側的賦值表達式可能並不像例子中給的 "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 做代碼掃描的基本原理,最後再以實際倉庫的掃描結果驗證檢測效果。目前來看,針對一些通用型的問題,通過代碼掃描確實能夠發現一些藏在代碼中的潛在資損/輿情風險。但是對於一些和業務邏輯強相關的資損風險目前仍不容易檢測,這還需從其他角度點進行突破。

關注「淘系技術」微信公眾號,一個有溫度有內容的技術社區~

image.png

Leave a Reply

Your email address will not be published. Required fields are marked *