Blog

· security · tree-sitter · red-teaming

Tree-sitter: Membaca Struktur Kode untuk Red Teaming

Saat membuka codebase yang belum dikenal, pertanyaan pertama biasanya bukan “di mana celahnya?”, tetapi “bagian mana yang menerima input, punya hak istimewa, dan menghubungkan keduanya?”. Membaca semua file satu per satu bisa menghabiskan waktu sebelum kita memahami alur yang penting.

Tree-sitter membantu membuat peta awal itu. Ia mengubah teks program menjadi struktur yang bisa ditelusuri dan dicari. Dari sana, kita dapat menyusun pertanyaan keamanan yang lebih spesifik, lalu mengujinya dalam konteks sistem yang sebenarnya.

Ini seri tiga bagian:

  1. Tree-sitter: membaca struktur kode dan menemukan titik pemeriksaan.
  2. Threat modeling dengan STRIDE: memahami aset, batas kepercayaan, dan kemungkinan kegagalannya.
  3. REA dan reverse engineering: memeriksa artefak yang didistribusikan dan menyusun pengujian red team.

Ketiganya memakai contoh fiktif Laporan, aplikasi Electron yang mengakses layanan SaaS untuk mengekspor laporan. Setiap pelanggan adalah tenant; data tenant A harus terpisah dari tenant B. Semua potongan kode dan skenario di seri ini adalah materi pembelajaran, bukan hasil audit aplikasi nyata.

Posisi Tree-sitter dalam red teaming

Red teaming menguji kemampuan organisasi menghadapi perilaku penyerang melalui skenario dan tujuan yang disepakati. Pemeriksaan kode dapat membantu memilih jalur pengujian, tetapi menemukan fungsi mencurigakan saja belum mengukur pertahanan organisasi.

Dalam seri ini, target pengujiannya adalah: bisakah akun pengguna biasa memperoleh laporan tenant lain, dan apakah kontrol keamanan serta tim pertahanan mengenali percobaannya? Kita perlu menjawab pertanyaan teknis sekaligus operasional.

MITRE menjelaskan adversary emulation sebagai cara menguji pertahanan berdasarkan perilaku adversary. Analisis struktur kode dapat menjadi salah satu sumber informasi untuk merancang perilaku yang relevan dengan aplikasi yang diuji.

Alur kerja yang dipakai di sini:

Kode atau artefak
      |
      v
Inventaris struktur -> hipotesis ancaman -> pengujian terbatas
                                               |
                                               v
                                bukti, respons, dan perbaikan

Ini pilihan workflow untuk contoh kita. Tidak semua kegiatan red team memiliki akses source code atau dimulai dengan analisis aplikasi.

Apa sebenarnya Tree-sitter?

Tree-sitter adalah parser generator sekaligus pustaka incremental parsing. Dengan grammar suatu bahasa, ia membangun concrete syntax tree, atau CST, dari source code. Ia juga dirancang tetap menghasilkan struktur yang berguna ketika kode memiliki kesalahan sintaks.

Bayangkan baris ini:

exportReport(request);

Sebagai teks, ia adalah deretan karakter. Sebagai pohon sintaks, bentuk sederhananya menjadi:

call_expression
  function: identifier "exportReport"
  arguments: arguments
    identifier "request"

Kita bisa mencari “pemanggilan fungsi dengan nama tertentu” tanpa perlu menganggap setiap kemunculan teks exportReport sebagai pemanggilan.

CST dan AST berbeda di tingkat detail

Abstract syntax tree, atau AST, biasanya menghilangkan sebagian detail sintaks untuk mewakili operasi program. CST Tree-sitter mempertahankan lebih banyak struktur sintaks, termasuk token seperti tanda kurung dan koma. Tree-sitter membedakan named nodes seperti call_expression dari anonymous nodes seperti token (.

Perbedaan ini berguna saat menulis query: kita dapat memilih konstruksi bahasa melalui named nodes, lalu memakai token tertentu jika diperlukan. Nama node dan field mengikuti grammar bahasa yang digunakan. Struktur JavaScript tidak otomatis sama dengan Python atau Go. Lihat dokumentasi node dan field untuk dasar pencocokannya.

Incremental parsing membantu saat kode berubah

Setelah menerima informasi tentang perubahan teks, parser dapat memakai kembali bagian pohon lama saat membangun pohon yang baru. Ini berguna untuk editor dan alat yang berulang kali menganalisis file yang diedit. Ia tidak berarti seluruh analisis keamanan ikut diperbarui otomatis; indeks dan hubungan antarfile tetap menjadi tanggung jawab alat yang memakai parser.

Kapan pencarian teks cukup, kapan perlu syntax tree?

rg sangat berguna untuk mencari nama endpoint, string konfigurasi, atau pesan error. Saya biasanya tetap memulainya dari sana. Tree-sitter membantu ketika pertanyaannya membutuhkan bentuk kode yang lebih spesifik.

Misalnya, kita ingin mencari pendaftaran handler Electron:

ipcMain.handle("reports:export", exportReport);

Pencarian ipcMain.handle juga menemukan komentar dan string yang membahas API itu. Query struktural bisa membatasi hasil pada call_expression dengan bentuk member access yang sesuai.

Namun, query tersebut dapat melewatkan alias:

const bridge = ipcMain;
bridge.handle("reports:export", exportReport);

Mengetahui bahwa bridge merujuk ke ipcMain memerlukan analisis tambahan. Syntax tree menyediakan bahan untuk analisis itu, tetapi tidak melakukan symbol resolution atau analisis aliran data secara otomatis.

Latihan: inventaris handler IPC

Dalam Electron, renderer menampilkan UI, sedangkan main process dapat menjalankan operasi dengan kemampuan sistem yang lebih luas. Pesan antarproses disebut inter-process communication, atau IPC. Hubungan ini akan menjadi salah satu batas kepercayaan dalam post STRIDE.

Untuk latihan, simpan kode berikut sebagai main.js di direktori baru:

import { ipcMain } from "electron";

ipcMain.handle("reports:export", async (event, request) => {
  return exportReport(request);
});

ipcMain.handle("app:version", () => "1.0.0");

// ipcMain.handle("debug:fake", () => {});
const note = 'ipcMain.handle("debug:fake", ...)';

Kita hanya membaca file ini sebagai data; contoh tidak menjalankan aplikasi Electron. Fungsi exportReport sengaja tidak diimplementasikan karena latihan ini hanya menginventaris pendaftaran handler.

Bentuk query

Query Tree-sitter memakai S-expression, yaitu pola dengan tanda kurung bersarang. Berikut query untuk grammar JavaScript:

(
  (call_expression
    function: (member_expression
      object: (identifier) @receiver
      property: (property_identifier) @method)
    arguments: (arguments
      . (string) @channel)) @registration
  (#eq? @receiver "ipcMain")
  (#eq? @method "handle")
)

function: dan arguments: adalah field pada node. @receiver, @method, dan @channel adalah capture: nama untuk mengambil bagian yang cocok. Titik sebelum (string) membatasi kecocokan ke named child pertama dalam daftar argumen, sehingga string yang diambil adalah nama channel pada bentuk pemanggilan ini.

Predicate #eq? membandingkan teks capture dengan nilai tertentu. Dengan begitu, otherObject.handle(...) tidak ikut dianggap sebagai pendaftaran ipcMain.handle(...). Dukungan predicate diproses oleh binding atau host query yang dipakai; contoh berikut memakai binding Python. Dasarnya ada di panduan predicate.

Menjalankan query dengan Python

Contoh ini memakai Python 3.10 atau lebih baru. Buat virtual environment di direktori latihan dan pasang versi yang dipatok agar API contoh konsisten:

python3 -m venv .venv
.venv/bin/python -m pip install tree-sitter==0.25.2 tree-sitter-javascript==0.25.0

Untuk Windows, gunakan executable Python di .venv\Scripts\python.exe. Versi di atas adalah pasangan versi untuk latihan ini, bukan klaim tentang rilis terbaru. Runtime parser dan grammar adalah paket terpisah.

Simpan script berikut sebagai scan_ipc.py:

import json
import sys
from pathlib import Path

import tree_sitter_javascript as javascript
from tree_sitter import Language, Parser, Query, QueryCursor

language = Language(javascript.language())
parser = Parser(language)
path = Path(sys.argv[1])
source = path.read_bytes()
tree = parser.parse(source)

if tree.root_node.has_error:
    raise SystemExit("Parse error: periksa file dan grammar sebelum membaca hasil.")

query = Query(language, r'''
(
  (call_expression
    function: (member_expression
      object: (identifier) @receiver
      property: (property_identifier) @method)
    arguments: (arguments
      . (string) @channel)) @registration
  (#eq? @receiver "ipcMain")
  (#eq? @method "handle")
)
''')

results = []
for _, captures in QueryCursor(query).matches(tree.root_node):
    call = captures["registration"][0]
    channel = captures["channel"][0]
    results.append({
        "file": str(path),
        "line": call.start_point.row + 1,
        "channel_literal": source[channel.start_byte:channel.end_byte].decode("utf-8"),
    })

results.sort(key=lambda item: item["line"])
print(json.dumps(results, ensure_ascii=False, indent=2))

Jalankan:

.venv/bin/python scan_ipc.py main.js

Untuk fixture tadi, hasil yang diharapkan adalah:

[
  {
    "file": "main.js",
    "line": 3,
    "channel_literal": "\"reports:export\""
  },
  {
    "file": "main.js",
    "line": 7,
    "channel_literal": "\"app:version\""
  }
]

channel_literal sengaja menyimpan literal beserta tanda kutipnya. Script tidak mengevaluasi JavaScript. Komentar dan string debug:fake tidak cocok dengan pola pemanggilan yang kita cari.

API Language, Parser, Query, dan QueryCursor mengikuti binding Python resmi. Offset byte digunakan untuk mengambil teks dari byte sumber; nomor baris diubah dari indeks nol menjadi nomor yang biasa ditampilkan editor.

Mengubah hasil pencarian menjadi pertanyaan keamanan

Hasil di atas hanya membuktikan bahwa ada dua bentuk pendaftaran yang cocok. Langkah berikutnya adalah membaca konteks di sekitar handler, bukan memberi label “vulnerable” berdasarkan nama channel.

Untuk reports:export, pertanyaannya dapat berkembang seperti ini:

Pengamatan Pertanyaan lanjutan
Handler menerima request Field mana yang berasal dari renderer?
Ada reportId dan tenantId Apakah server memverifikasi kepemilikan laporan?
Main process menulis file Siapa yang menentukan lokasi dan nama file?
Export memakai kredensial pengguna Apakah kewenangan diperiksa untuk setiap operasi?
Ada audit event Apakah actor, tenant, objek, dan hasilnya bisa dikorelasikan?

Setiap jawaban membutuhkan bukti lain. Validasi di preload tidak membuktikan validasi di main process. Pemeriksaan tenant di UI tidak membuktikan isolasi tenant di API. Sebaliknya, tidak melihat pemeriksaan di satu fungsi juga belum membuktikan bahwa pemeriksaan tidak dilakukan oleh lapisan lain.

Source, sink, dan jalur yang menghubungkan keduanya

Dalam analisis keamanan, source adalah asal data yang relevan, misalnya payload dari renderer. Sink adalah operasi yang perlu dikendalikan, misalnya penulisan file atau pengambilan laporan sensitif.

Kandidat jalurnya dapat berbentuk:

request dari renderer
  -> handler reports:export
  -> request API
  -> respons laporan
  -> penulisan file lokal

Tree-sitter membantu menemukan konstruksi pembentuk jalur ini. Untuk menyimpulkan bahwa input tertentu benar-benar mencapai sink, kita masih perlu mengikuti pemanggilan, transformasi nilai, cabang kondisi, serta kontrol akses di sisi layanan.

Batas yang perlu dicatat dalam inventaris

Query latihan hanya mendeteksi bentuk ipcMain.handle dengan receiver berupa identifier dan channel berupa string literal. Ia melewatkan alias, computed property seperti ipcMain[method], channel dinamis, dan wrapper pendaftaran. Sebaliknya, objek lokal lain yang kebetulan bernama ipcMain dapat menghasilkan kecocokan yang keliru secara makna.

Pada codebase nyata, catat juga cakupan file: apakah hanya source utama, termasuk generated code, atau juga bundle distribusi? Apakah TypeScript dan TSX memakai grammar yang sesuai? File yang gagal diparse harus masuk catatan keterbatasan, bukan diam-diam dihitung sebagai “tidak ada temuan”. Tree-sitter memang dapat pulih dari kesalahan sintaks, tetapi script latihan sengaja berhenti agar hasil parsial tidak disalahartikan.

Untuk pekerjaan berulang, simpan versi grammar, versi query, identitas commit atau hash file, serta lokasi setiap kecocokan. Tambahkan fixture yang seharusnya cocok dan yang seharusnya tidak cocok. Dengan begitu, perubahan jumlah hasil bisa ditelusuri penyebabnya.

Apa yang dibawa ke threat modeling?

Keluaran yang berguna dari tahap ini adalah inventaris entry point, operasi sensitif, kandidat hubungan antarbagian, dan pertanyaan yang belum terjawab. Jumlah kecocokan bukan ukuran jumlah celah, dan hasil kosong bukan sertifikat keamanan.

Untuk contoh Laporan, kita membawa satu pertanyaan utama: ketika renderer meminta ekspor, lapisan mana yang berhak memutuskan identitas pengguna, tenant, dan laporan yang boleh diambil?

Di bagian kedua, kita menggambar alurnya, menandai batas kepercayaan, lalu memakai enam kategori STRIDE untuk mengubah pertanyaan tersebut menjadi skenario yang bisa diuji.