Rust研究：JVN、XML、SAX

Rust 初心者です。だいたい三週間くらい経過したと思います。

XML データを Rust で処理するにはどうすればいいのか調べてみました。

コンパイルが通るところまでできたのでメモしておきます。

# お題

JPCert/CC と IPA で運営されている JVN (Japan Vulnerability Notes) が公開している過去の脆弱性レポートのデータから、以下の項目を取り出したい。

* 脆弱性レポートのID（JVNDB-YYYY-NNNNNN）
* タイトル
* 概要
* 脆弱性の影響
* 脆弱性のあるプロダクトの情報（製品名・CPE・バージョン）
* CVSS 情報（Baseベクタとスコア）

脆弱性レポートのデータは XML 形式であり、次のような構造をしている。（一部を抜粋）

![jvndb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/8987a97f-7b43-e023-1c01-e921ae3ef937.png)

# 検討

Rust の XML ライブラリとして、xml-rs (https://crates.io/crates/xml-rs) を見つけました。
DOM パーサではなく SAX パーサのライブラリだったので少し検討が必要でした。

お題の項目に関係する部分の XML を受理するパーサの状態遷移図を作成しました。

![status.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/7002d134-89ab-2bad-aa02-16bfb0351b1d.png)

"START" を開始状態として、SAX で取得したトークンに応じて状態が遷移します。
この状態遷移を愚直に実装していきます。

# ソースコード

Cargo.toml

```toml
[package]
name = "study0206"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
xml-rs = "0.8"
```

```rust
// lib.rs
pub mod error;
pub mod conf;
pub mod xml;
```

```rust
// xml.rs
use std::fs::read_to_string;
use crate::error::ErrorCode;
use xml::reader::{EventReader, XmlEvent};

// Status は XML のパース途中の「状態」を表す
#[derive(Debug,PartialEq,Copy,Clone)]
enum Status {
    Start,
    Vulinfo,
    VulinfoID,
    VulinfoData,
    Title,
    VulinfoDescription,
    Overview,
    Affected,
    AffectedItem,
    Name,
    ProductName,
    Cpe,
    VersionNumber,
    Impact,
    Cvss,
    Base,
    Vector,
    ImpactItem,
    Description,

}

// TokenType はトークンのタイプを表現
enum TokenType {
    None,
    StartTag,
    EndTag,
    Characters,
}

// [WARNING]
// 入力される XML は整形式 (well-formed) であることを前提としている。
// そうでない場合はこの実装は破綻することに注意。
pub fn load_jvn_xml(file_path:&str) -> Result<Vec<Vulinfo>, ErrorCode> {
    let content = match read_to_string(file_path) {
        Ok(c) => c,
        Err(e) => return Err(ErrorCode::IOError(format!("{:?}", e)))
    };

    // ファイルが空のときはエラーを返す
    if content.len() < 1 {
        return Err(ErrorCode::EmptyFileError(file_path.to_string()))
    }

    let mut jvn_parser = JVNParser::new();
    let tokenizer = EventReader::from_str(&content);

    // トークンを読み出し
    for token in tokenizer {

        let mut token_type:TokenType = TokenType::None;
        let mut token_value = String::new();

        // トークンを分類する
        match token {

            // トークンが開始タグの時
            Ok(XmlEvent::StartElement {name, ..}) => {
                token_type = TokenType::StartTag;
                token_value = name.local_name.clone();
            },
            // トークンが終了タグの時
            Ok(XmlEvent::EndElement {name, ..}) => {
                token_type = TokenType::EndTag;
                token_value = name.local_name.clone();
            },
            // トークンが文字列の時
            Ok(XmlEvent::Characters(c)) => {
                token_type = TokenType::Characters;
                token_value += &c
            },
            // トークンが文字列(CData)の時
            Ok(XmlEvent::CData(c)) => {
                token_type = TokenType::Characters;
                token_value += &c
            },
            Err(e) => return Err(ErrorCode::RuntimeError(format!("{:?}", e))),
            _ => {}
        }

        // トークンをパーサに処理させる
        jvn_parser.process(token_type, token_value);
    }

    // パーサで抽出したデータを返す
    Ok(jvn_parser.get_result())
}

// JVN の XML のパーサ
struct JVNParser {
    // 現在のステータス
    status:Status,
    // 抽出した Vulinfo データのリスト
    result:Vec<Vulinfo>,
    // 抽出した Vulinfo データを一時的に格納する変数
    vulinfo:Vulinfo,
    // 抽出した AffectedItem データを一時的に格納する変数
    affected_item:AffectedItem,
    // 
    cvss:Cvss,
}

impl JVNParser {
    fn new () -> Self {
        JVNParser {
            status: Status::Start,
            result: vec![],
            vulinfo: Vulinfo::new(),
            affected_item: AffectedItem::new(),
            cvss:Cvss::new(),
        }
    }

    fn get_result (&self) -> Vec<Vulinfo> {
        self.result.clone()
    }

    fn process (&mut self, token_type:TokenType, token_value:String) {
        let token_value:&str = &token_value;

        // 状態遷移を表現：(現在の状態,トークンの型,トークンの値) の組み合わせで次の状態を判定する
        match (self.status, token_type, token_value) {

            // トークンが開始タグのケース

            (Status::Start, TokenType::StartTag, "Vulinfo") => {
                // Vulinfo ノードの開始なので vulinfo の値をクリアしておく
                self.vulinfo.clear();
                self.status = Status::Vulinfo;    
            },
            (Status::Vulinfo, TokenType::StartTag, "VulinfoID") => {
                self.status = Status::VulinfoID;
            },
            (Status::Vulinfo, TokenType::StartTag, "VulinfoData") => {
                self.status = Status::VulinfoData;
            },
            (Status::VulinfoData, TokenType::StartTag, "Title") => {
                self.status = Status::Title;
            },
            (Status::VulinfoData, TokenType::StartTag, "VulinfoDescription") => {
                self.status = Status::VulinfoDescription;
            },
            (Status::VulinfoDescription, TokenType::StartTag, "Overview") => {
                self.status = Status::Overview;
            },
            (Status::VulinfoData, TokenType::StartTag, "Affected") => {
                self.status = Status::Affected;
            },
            (Status::Affected, TokenType::StartTag, "AffectedItem") => {
                // AffectedItem ノードの始まりなので affected_item のデータをクリアしておく
                self.affected_item.clear();
                self.status = Status::AffectedItem;
            },
            (Status::AffectedItem, TokenType::StartTag, "Name") => {
                self.status = Status::Name;
            },
            (Status::AffectedItem, TokenType::StartTag, "ProductName") => {
                self.status = Status::ProductName;
            },
            (Status::AffectedItem, TokenType::StartTag, "Cpe") => {
                self.status = Status::Cpe;
            },
            (Status::AffectedItem, TokenType::StartTag, "VersionNumber") => {
                self.status = Status::VersionNumber;
            },
            (Status::VulinfoData, TokenType::StartTag, "Impact") => {
                self.status = Status::Impact;
            },
            (Status::Impact, TokenType::StartTag, "Cvss") => {
                self.cvss.clear();
                self.status = Status::Cvss;
            },
            (Status::Cvss, TokenType::StartTag, "Base") => {
                self.status = Status::Base;
            },
            (Status::Cvss, TokenType::StartTag, "Vector") => {
                self.status = Status::Vector;
            },
            (Status::Impact, TokenType::StartTag, "ImpactItem") => {
                self.status = Status::ImpactItem;
            },
            (Status::ImpactItem, TokenType::StartTag, "Description") => {
                self.status = Status::Description;
            },

            // トークンが終了タグのケース

            (Status::Vulinfo, TokenType::EndTag, "Vulinfo") => {
                // Vulinfo ノードの終わりなので vulinfo のデータが空でないなら保管する
                if !self.vulinfo.id.is_empty() {
                    self.result.push(self.vulinfo.clone());
                }
                self.status = Status::Start;
            },
            (Status::VulinfoID, TokenType::EndTag, "VulinfoID") => {
                self.status = Status::Vulinfo;
            },
            (Status::VulinfoData, TokenType::EndTag, "VulinfoData") => {
                self.status = Status::Vulinfo;
            },
            (Status::Title, TokenType::EndTag, "Title") => {
                self.status = Status::VulinfoData;
            },
            (Status::VulinfoDescription, TokenType::EndTag, "VulinfoDescription") => {
                self.status = Status::VulinfoData;
            },
            (Status::Overview, TokenType::EndTag, "Overview") => {
                self.status = Status::VulinfoDescription;
            },
            (Status::Affected, TokenType::EndTag, "Affected") => {
                self.status = Status::VulinfoData;
            },
            (Status::AffectedItem, TokenType::EndTag, "AffectedItem") => {
                // AffectedItem ノードの終わりなので affected_item のデータが空でなければ保管する
                if ! self.affected_item.is_empty() {
                    self.vulinfo.affected.push(self.affected_item.clone());
                }
                self.status = Status::Affected;
            },
            (Status::Name, TokenType::EndTag, "Name") => {
                self.status = Status::AffectedItem;
            },
            (Status::ProductName, TokenType::EndTag, "ProductName") => {
                self.status = Status::AffectedItem;
            },
            (Status::Cpe, TokenType::EndTag, "Cpe") => {
                self.status = Status::AffectedItem;
            },
            (Status::VersionNumber, TokenType::EndTag, "VersionNumber") => {
                self.status = Status::AffectedItem;
            },
            (Status::Impact, TokenType::EndTag, "Impact") => {
                self.status = Status::VulinfoData;
            },
            (Status::Cvss, TokenType::EndTag, "Cvss") => {
                if ! self.cvss.is_empty() {
                    self.vulinfo.cvss.push(self.cvss.clone());
                }
                self.status = Status::Impact;
            },
            (Status::Base, TokenType::EndTag, "Base") => {
                self.status = Status::Cvss;
            },
            (Status::Vector, TokenType::EndTag, "Vector") => {
                self.status = Status::Cvss;
            },
            (Status::ImpactItem, TokenType::EndTag, "ImpactItem") => {
                self.status = Status::Impact;
            },
            (Status::Description, TokenType::EndTag, "Description") => {
                self.status = Status::ImpactItem;
            },

            // トークンが文字列のケース。取得した文字列は保管。いずれも終了タグが来るまで同じ状態に留まる。

            (Status::VulinfoID, TokenType::Characters, c) => {
                self.vulinfo.id += &c;
            },
            (Status::Title, TokenType::Characters, c) => {
                self.vulinfo.title += &c;
            },
            (Status::Overview, TokenType::Characters, c) => {
                self.vulinfo.overview += &c;
            },
            (Status::Name, TokenType::Characters, c) => {
                self.affected_item.name += &c;
            },
            (Status::ProductName, TokenType::Characters, c) => {
                self.affected_item.product_name += &c;
            },
            (Status::Cpe, TokenType::Characters, c) => {
                self.affected_item.cpe += &c;
            },
            (Status::VersionNumber, TokenType::Characters, c) => {
                self.affected_item.version_number += &c;
            },
            (Status::Base, TokenType::Characters, c) => {
                self.cvss.score += &c;
            },
            (Status::Vector, TokenType::Characters, c) => {
                self.cvss.vector += &c;
            },
            (Status::Description, TokenType::Characters, c) => {
                println!("description! {}", c);
                self.vulinfo.impact_description += &c;
            },


            // ★この位置に新しい状態遷移のアームを追加する★
            
            _ => { /* ignore */ }
        }

    }

 }

#[derive(Debug, Clone)]
pub struct Vulinfo {
    pub id:String,
    pub title:String,
    pub overview:String,
    pub affected:Vec<AffectedItem>,
    pub cvss:Vec<Cvss>,
    pub impact_description:String,
}

#[derive(Debug, Clone)]
pub struct AffectedItem {
    pub name:String,
    pub product_name:String,
    pub cpe:String,
    pub version_number:String,
}

#[derive(Debug, Clone)]
pub struct Cvss {
    pub score:String,
    pub vector:String,
}

impl Vulinfo {

    pub fn new() -> Self {
        Vulinfo {
            id:String::new(),
            title:String::new(),
            overview:String::new(),
            impact_description:String::new(),
            affected: vec![],
            cvss: vec![],
        }
    }

    pub fn to_string(&self) -> String {
        let mut r = format!("id={id}\ntitle={title}\noverview={overview}\nimpact_description={impact_description}",
            id=self.id,
            title=self.title,
            overview=self.overview,
            impact_description=self.impact_description);
        for affected_item in &self.affected {
            r += "\n";
            r += &affected_item.to_string()
        }
        for cvss in &self.cvss {
            r += "\n";
            r += &cvss.to_string()
        }
        r
    }

    pub fn clear(&mut self) {
        self.id = String::new();
        self.title = String::new();
        self.overview = String::new();
        self.affected = vec![];
        self.cvss = vec![];
    }

    pub fn is_empty(&self) -> bool {
        // [WARNING]
        // VulinfoID が "" となるケースはまだ確認できていない
        self.id == ""
    }
}

impl AffectedItem {

    pub fn new() -> Self {
        AffectedItem {
            name: String::new(),
            product_name: String::new(),
            cpe: String::new(),
            version_number: String::new(),
        }
    }

    pub fn clear(&mut self) {
        self.name = String::new();
        self.product_name = String::new();
        self.cpe = String::new();
        self.version_number = String::new();
    }

    pub fn is_empty(&self) -> bool {
        // [WARNING]
        // AffectedItem の Name が "" となるケースはまだ確認できていない
        self.name == ""
    }

    pub fn to_string(&self) -> String {
        format!("name={name}\nproduct_name={product_name}\ncpe={cpe}\nversion_number={version_number}",
            name=self.name,
            product_name=self.product_name,
            cpe=self.cpe,
            version_number=self.version_number)
    }
}

impl Cvss {

    pub fn new() -> Self {
        Cvss {
            score: String::new(),
            vector: String::new(),
        }
    }

    pub fn clear(&mut self) {
        self.score = String::new();
        self.vector = String::new();
    }

    pub fn is_empty(&self) -> bool {
        self.vector == ""
    }

    pub fn to_string(&self) -> String {
        format!("vector={vector}\nscore={score}",
            vector=self.vector,
            score=self.score)
    }
}
```



```rust
// conf.rs

use std::env;
use std::path::Path;
use crate::error::ErrorCode;

pub struct Conf {
    files: Vec<String>,
}

impl Conf {

    pub fn new() -> Result<Self, ErrorCode> {
        // 引数をチェック
        let args:Vec<String> = env::args().collect();
        if args.len() < 2 {
            return Err(ErrorCode::ArgumentError)
        }

        // 処理対象ファイルのリストを用意
        let mut files: Vec<String> = vec![];

        // 処理対象ファイルのリストを作成
        for file in &args[1..] {
            validate_file_path(file)?; // ファイルパスをチェック。なければエラー
            files.push(file.to_string())
        }

        Ok(Conf{files: files})
    }

    pub fn get_files(&self) -> &Vec<String> {
        &self.files
    }
}

fn validate_file_path(file_path: &str) -> Result<(), ErrorCode> {
    let pb = Path::new(file_path);

    if !pb.exists() {
        return Err(ErrorCode::FileNotFoundError(file_path.to_string()))
    }

    Ok(())
}
```

```rust
// error.rs
use std::process::{Termination,ExitCode};

pub enum ErrorCode {
    Success,
    ArgumentError,
    FileNotFoundError(String),
    EmptyFileError(String),
    IOError(String),
    RuntimeError(String),
}

impl ErrorCode {
    pub fn to_value(&self) -> u8 {
        match self {
            ErrorCode::Success => 0,
            ErrorCode::ArgumentError => 10,
            ErrorCode::FileNotFoundError(_) => 20,
            ErrorCode::EmptyFileError(_) => 21,

            ErrorCode::IOError(_) => 22,
            ErrorCode::RuntimeError(_) => 30,
        }
    }

    pub fn to_string(&self) -> String {
        match self {
            ErrorCode::Success => "Success".to_string(),
            ErrorCode::ArgumentError => "[Usage] study0206.exe jvndb.xml ...".to_string(),
            ErrorCode::FileNotFoundError(file_path) => format!("[File Not Found] file:{}", file_path),
            ErrorCode::EmptyFileError(file_path) => format!("[Empty File] file:{}", file_path),

            ErrorCode::IOError(msg) => format!("[IO Error] {}", msg),
            ErrorCode::RuntimeError(msg) => format!("[Runtime Error] {}", msg),
        }
    }

}

// ErrorCode に Termination トレイトを実装する
impl Termination for ErrorCode {
    fn report(self) -> ExitCode {
        ExitCode::from(self.to_value())
    }
}
```

```rust
// main.rs
use study0206::conf::Conf;
use study0206::error::ErrorCode;
use study0206::xml::load_jvn_xml;

fn main() -> ErrorCode {
    let conf = match Conf::new() {
        Ok(conf) => conf,
        Err(e) => return console(e)
    };

    match run(conf) {
        Ok(()) => ErrorCode::Success,
        Err(e) => console(e)
    }
}

fn run(conf:Conf) -> Result<(),ErrorCode> {

    for file in conf.get_files() {
        println!("file = {}", file);
        for vulinfo in load_jvn_xml(file)? {
            println!("----------------------------");
            println!("{}", vulinfo.to_string())
        }
    }

    Ok(())
}

fn console(e:ErrorCode) -> ErrorCode {
    eprintln!("[ERROR] {}", e.to_string());
    e
}
```

実行例

```
C:\work\study0206>curl --silent --output jvndb_detail_2023.xml https://jvndb.jvn.jp/ja/feed/detail/jvndb_detail_2023.rdf
C:\work\study0206>target\debug\study0206.exe
[ERROR] [Usage] study0206.exe jvndb.xml ...
C:\work\study0206>echo %errorlevel%
10
C:\work\study0206>target\debug\study0206.exe jvndb_detail_2023.xml
file = jvndb_detail_2023.xml
----------------------------
id=JVNDB-2023-000001
title=ruby-git における複数のコードインジェクションの脆弱性コード・インジェクション
overview=ruby-git は、Git リポジトリの作成、読み取り、および操作に使用できる Ruby ライブラリです。ruby-git には、複数のコードインジェクション (CWE-94) の脆弱性が存在します。  この脆弱性情報は、情報セキュリティ早期警戒パートナーシップに基づき下記の方が IPA に報告し、JPCERT/CC が開発者との調整を行いました。 報告者: 株式会社ディー・エヌ・エー 国分佑樹 氏
impact_description=当該製品で、第三者によって細工されたファイル名を含むリポジトリを読み込むと、任意の ruby コードを実行される可能性があります。
name=ruby-git
product_name=ruby-git
cpe=cpe:/a:misc:ruby-git_ruby-git
version_number=v1.13.0 より前のバージョン
vector=AV:N/AC:M/Au:S/C:P/I:P/A:P
score=6
vector=CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:L
score=5.5
----------------------------
id=JVNDB-2023-000002
title=デジタルアーツ製 m-FILTER における認証不備の脆弱性不適切な認証
overview=デジタルアーツ株式会社が提供する m-FILTER は、メールセキュリティ製品です。 m-FILTER には、特定の条件下においてメール送信時に認証不備の脆弱性 (CWE-287) があり、第三者によって意図しないメールを送信される問題が存在します。  なお、開発者によると、本脆弱性を悪用した攻撃が既に確認されているとのことです。  この脆弱性情報は、製品利用者への周知を目的に、開発者が IPA に報告し、JPCERT/CC が開発者との調整を行いました。
impact_description=当該製品で、第三者によって細工されたファイル名を含むリポジトリを読み込むと、任意の ruby コードを実行される可能性があります。本脆弱性を悪用された場合、次のような影響を受ける可能性があります。<ul><li>送信元の IP アドレスがブラックリストに登録され、メールを送信できなくなる<li>アーカイブを利用している場合、不正に送信されたメールがアーカイブされ、ディスク使用量を圧迫させられる</ul>
name=デジタルアーツ株式会社
product_name=m-FILTER
cpe=cpe:/a:daj:m-filter
version_number=Ver.4.87R04 より前のバージョン (Ver.4系)Ver.5.70R01 より前のバージョン (Ver.5系)
vector=AV:N/AC:M/Au:N/C:N/I:N/A:P
score=4.3
vector=CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
score=5.3
…(省略)…

C:\work\study0206>echo %errorlevel%
0
```

ここまでの感想。

* JVNParser::process メソッドの状態遷移の実装。当初はトークンタイプごとに個別の状態遷移を実装していたのですが、現在の状態・トークンタイプ・トークンの内容の三項組のタプルを使って match 分岐すればよいことに気が付いたときは少しスカッとしました。
* SAX パーサを使っての状態遷移の実装はめんどくさかったです。それでも SAX パーサは DOM パーサよりもメモリリソースを消費しないし今回のように必要な部分だけを抽出するのには向いていたと思います。
* 今回 JVN のサイトから脆弱性レポートのデータをダウンロードするところは実装しませんでした。実行例のように curl コマンドでダウンロードすることを前提としました。
* Rust の関数やメソッドでオブジェクトを返すような場合に「クローン」か「参照」かで悩んできましたが、これまでみてきた中では「参照」で返そうとすると複雑になる印象です。最近は「クローン」派に落ち着きました。