はじめに

今回は番外編です。2023年7月15日にISACA名古屋支部のSR分科会にお邪魔した際、次のようなハンズオンのお題がありました。

  1. JPCERT/CCのフィッシングURLのCSVファイルから、2022年度(4月~3月)における事業者ごとの週次集計を図示すること。
  2. ただし、個別のグラフ化は上位5事業者とし、残りはOthersとして1本にまとめること。

※本来は上位30事業者の図示でしたが、ここでは見やすさのために5事業者にしました。

このお題は、tidyverseパッケージを使ってデータを取得・加工・視覚化する好適例なので、ここでWrite-Upする次第です。

パッケージを読み込む

# install.pacakages("pacman")
pacman::p_load(tidyverse, arrow, scales)

最近はライブラリの読み込みに、pacmanが紹介されることが増えました。 p_load()は、既に読み込まれていたら無視し、読み込まれておらず環境にあれば読み込んでくれ、環境になければCRANからダウンロードしてインストール・読み込みを行ってくれる、とても楽勝感の強い関数です。私も最近は専らp_load()を使っています。

arrowは、ダウンロードして作ったデータフレームをApache Feather形式(RやPythonで共通して使えるデータフレームのファイル保存形式)で保存するため、 scalesは、ggplotの作図で軸の表現を簡単に行うために読み込みます。

データの所在を確認する

JPCERT/CCのフィッシングURLリストは、 GitHubのディレクトリに保管されています。

試しに2022年4月の生データのURLを調べると、次のようになっていました。

https://raw.githubusercontent.com/JPCERTCC/phishurl-list/main/2022/202204.csv

これを踏まえ、2022年4月から2023年3月までのダウンロードURLリストを作ります。

base_url <- "https://github.com/JPCERTCC/phishurl-list/raw/main/"
files <- 
  seq(ymd("2022-04-01"), ymd("2023-03-31"), by="month") |> 
  strftime("%Y/%Y%m.csv")

urls <- str_c(base_url, files)

このうち、以下の部分がコツです。

seq(ymd("2022-04-01"), ymd("2023-03-31"), by="month") |> strftime("%Y/%Y%m.csv")

パイプの左側のseq()は、シーケンスを作るBase R関数です。間隔のbyが柔軟で、「月ごと」といった指定が可能になっています。

seq(ymd("2022-04-01"), ymd("2023-03-31"), by="month")
##  [1] "2022-04-01" "2022-05-01" "2022-06-01" "2022-07-01" "2022-08-01"
##  [6] "2022-09-01" "2022-10-01" "2022-11-01" "2022-12-01" "2023-01-01"
## [11] "2023-02-01" "2023-03-01"

上でDate型のベクトルができたので、strftime()を使って文字列型とするときに、 JPCERT/CCのディレクトリ構造に合わせて「YYYY/YYYYMM.csv」となるようにします。

seq(ymd("2022-04-01"), ymd("2023-03-31"), by="month") |> strftime("%Y/%Y%m.csv")
##  [1] "2022/202204.csv" "2022/202205.csv" "2022/202206.csv" "2022/202207.csv"
##  [5] "2022/202208.csv" "2022/202209.csv" "2022/202210.csv" "2022/202211.csv"
##  [9] "2022/202212.csv" "2023/202301.csv" "2023/202302.csv" "2023/202303.csv"

これをstr_c()でbase_urlと連結させたわけです。

データを取得し保存する

先ほど要素を12個もつ文字列ベクトルができたので、これらを read_csv()で読み込みます。read_csv()は、圧縮形式やURL形式にも対応しているのでした。

events <- map_dfr(urls, read_csv)
write_feather(events, "data/jpcertcc_phishurl_fy22.feather")

read_csv()を12回書くのは面倒なので、map_*()関数で一括処理します。 tidyverseでは、for文に相当することはmap_*()関数で済みます。 map_dfrなので、戻り値はデータフレーム(tibble)です。

次にwrite_feather()で、作ったデータフレーム(events)を保存しておきます。保存したデータフレームは、

events <- read_feather("data/jpcertcc_phishurl_fy22.feather")

のようにすることで呼び出せます。

ちなみに現在では、map_dfr()はあまり推奨されていません(Superseded扱い)。これは、map_dfr()が他のmap_*()と内部的に異なる挙動をするためです。

かわりに推奨されている書き方は

map(urls, read_csv) |> list_rbind()

と、いちどリストで取得してから縦方向にデータフレームとして結合することなのですが、私自身は新しい書き方に価値を見出せません。論理的な整合性は向上するものの、感覚的な整合性は落ちているように見えるからです。 map_dfr()は、データフレームを返すという点で、動作の内部的挙動はどうあれ map_chr()やmap_dbl()と同じように「結果」が類推できます。

週次加工とOthers加工

今回、図解には2つ条件がありました。

  1. 集計単位は週次とする。
  2. 上位5事業者以外はOthersで集約する。

これらを実現するために、先ほどのデータフレームeventsを加工します。

events_mod <- 
  events |> 
    mutate(
      date = as_date(date),
      week = floor_date(date, unit = "week"),
      desc = fct_lump_n(description, n = 5, other_level = "Others")
  )

floor_date()は切り下げの関数で、単位を週とした場合、デフォルトでは直前の日曜日にまで日付が切り下げられます。

fct_lump_*()はForcatパッケージに含まれる関数で、fct_lump_n()とすると上位n件以外を一塊(lump)にします。ググってみると日本語のサイトで取り上げているものがなかったので、記載することにしました。

ちゃんとできているか、データフレームの先頭10行を出力してみましょう。(フィッシングURLがリンクになるとまずいので、以下では出力から落としています。) 4月1日(金)が3月27日(日)に切り落とされ、また「エポスカード」がOthersになっているので、大丈夫そうです。

events_mod |> 
  select(!URL) |> 
  slice_head(n = 10)
date description week desc
2022-04-01 三菱UFJニコス 2022-03-27 三菱UFJニコス
2022-04-01 三菱UFJニコス 2022-03-27 三菱UFJニコス
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 Amazon 2022-03-27 Amazon
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 エポスカード 2022-03-27 Others
2022-04-01 エポスカード 2022-03-27 Others

視覚化する

ここまでくれば、あとはggplotで視覚化するだけです。最低限必要なのは、

events_mod |> 
  ggplot(aes(x = week, color = desc)) +
  geom_line(stat = "count")

です。今回はイベントデータを直接ggplotにもっていきましたので、折れ線グラフのオプションで「stats = “count”」として集計するようにしています。私自身は、先にデータフレーム自体を週次に加工し、 ggplotの負担を小さくすることが多いです(その手法は末尾に掲載しました)。

軸周辺が見づらいので、簡単に手入れをして完成です。

events_mod |> 
  ggplot(aes(x = week, color = desc)) +
  geom_line(stat = "count", alpha = 0.8) +
  scale_x_date(date_breaks = "2 weeks",
               minor_breaks = "week",
               limits = c(ymd("2022-03-25"), ymd("2023-03-28")),
               expand = c(0.01, 0.01),
               labels = label_date_short(format = c("%Y", "%b.", "%e", "%H"), sep = "\n")
  ) +
  labs(x = "Week", y = "Distinct Count of URLs", color = "Description") +
  theme(
    legend.position = "bottom",
    legend.justification = "center"
  )

おわりに

データの取得から加工、視覚化まで、tidyverseを使うと短いコードで、プログラミングっぽさを前面に押し出さずに実現できます。

参考

コード片を1つにまとめておきます。下では、データフレーム上で先に集計する方式を採用しました。

# install.pacakages("pacman")
pacman::p_load(tidyverse, arrow, scales)

base_url <- "https://github.com/JPCERTCC/phishurl-list/raw/main/"
files <- 
  seq(ymd("2022-04-01"), ymd("2023-03-31"), by="month") |> 
  strftime("%Y/%Y%m.csv")

urls <- str_c(base_url, files)
events <- map_dfr(urls, read_csv)

write_feather(events, "data/jpcertcc_phishurl_fy22.feather")

events |> 
  mutate(
    date = as_date(date),
    week = floor_date(date, unit = "week"),
    desc = fct_lump_n(description, n = 5, other_level = "Others")
    ) |> 
  group_by(week, desc) |> 
  tally() |> 
  ggplot(aes(x = week, y = n, color = desc)) +
  geom_line(alpha = 0.8) +
  scale_x_date(date_breaks = "2 weeks",
               minor_breaks = "week",
               limits = c(ymd("2022-03-25"), ymd("2023-03-28")),
               expand = c(0.01, 0.01),
               labels = label_date_short(format = c("%Y", "%b.", "%e", "%H"), sep = "\n")
  ) +
  labs(x = "Week", y = "Distinct Count of URLs", color = "Description") +
  theme(
    legend.position = "bottom",
    legend.justification = "center"
  )