Lewati ke konten

Audit statis API

Menangkap impor hilang, token di query string, dan kolom bocor.

Berkas: alat/hari-3/Praktik 9/tools/audit-api.mjs

Praktik 9/tools/audit-api.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 9 - audit-api.mjs
//
// Pemeriksa statis untuk berkas API di "Praktik 9/Membuat CRUD API users".
// Tidak butuh eslint, typescript, atau proses build — cukup node.
//
// Yang diperiksa:
// - identifier yang dipakai tapi tidak pernah di-import/dideklarasikan
// (menangkap ReferenceError seperti verifyAccessToken tanpa import)
// - token yang boleh dikirim lewat query string (?access_token=...)
// - kolom sensitif yang ikut terkirim ke klien (password, hash)
// - kunci objek yang ditulis dua kali (nilai pertama tertimpa diam-diam)
// - endpoint yang tidak memakai requireAuth sama sekali
// - pesan error database yang diteruskan mentah ke klien
// - nilai rahasia yang punya nilai default (tidak gagal saat env kosong)
//
// Pakai:
// node audit-api.mjs # audit folder bernama "Membuat CRUD API users"
// node audit-api.mjs <folder> # audit folder lain
// node audit-api.mjs <folder> --json # keluaran JSON
// =====================================================================
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative, basename } from 'node:path';
const FOLDER_DEFAULT = 'Membuat CRUD API users';
// ---------------------------------------------------------------- argumen
const argv = process.argv.slice(2);
const keluaranJson = argv.includes('--json');
const posisi = argv.filter((a) => !a.startsWith('--'));
const akarPraktik9 = posisi[0] ?? new URL('..', import.meta.url).pathname;
const target = existsSync(join(akarPraktik9, FOLDER_DEFAULT))
? join(akarPraktik9, FOLDER_DEFAULT)
: akarPraktik9;
if (!existsSync(target)) {
console.error(`Folder tidak ditemukan: ${target}`);
process.exit(2);
}
// ---------------------------------------------------------------- glob JS
function cariBerkasJs(direktori) {
const hasil = [];
for (const nama of readdirSync(direktori)) {
const jalur = join(direktori, nama);
if (statSync(jalur).isDirectory()) hasil.push(...cariBerkasJs(jalur));
else if (nama.endsWith('.js') || nama.endsWith('.mjs') || nama.endsWith('.jsx')) hasil.push(jalur);
}
return hasil;
}
// ---------------------------------------------------------------- util
function nomorBaris(teks, indeks) {
let n = 1;
for (let i = 0; i < indeks && i < teks.length; i++) if (teks[i] === '\n') n++;
return n;
}
// Membuang komentar dan literal string/template supaya tidak menghasilkan
// positif palsu saat mencari identifier.
//
// Akhiran baris diseragamkan lebih dahulu: berkas latihan memakai CRLF, dan
// "\r" dianggap \s sehingga pola seperti /\bfunction\s+nama\s*\(/ gagal
// mencocokkan "function GET(\r\n" -> " " -> "(".
function bersihkan(teks) {
teks = teks.replace(/\r\n?/g, '\n');
let hasil = '';
let i = 0;
while (i < teks.length) {
const c = teks[i];
const d = teks[i + 1];
if (c === '/' && d === '/') { while (i < teks.length && teks[i] !== '\n') i++; continue; }
if (c === '/' && d === '*') { i += 2; while (i < teks.length && !(teks[i] === '*' && teks[i + 1] === '/')) i++; i += 2; continue; }
if (c === '"' || c === "'" || c === '`') {
const kutip = c;
hasil += kutip;
i++;
while (i < teks.length) {
if (teks[i] === '\\') { i += 2; continue; }
if (teks[i] === kutip) { i++; break; }
// newline dipertahankan agar nomor baris tetap akurat
if (teks[i] === '\n') hasil += '\n';
i++;
}
hasil += kutip;
continue;
}
hasil += c;
i++;
}
return hasil;
}
const GLOBAL_DIKENAL = new Set([
'console', 'process', 'globalThis', 'JSON', 'Math', 'Date', 'Object', 'Array', 'String',
'Number', 'Boolean', 'Promise', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'BigInt',
'Error', 'TypeError', 'RangeError', 'RegExp', 'Function', 'Reflect', 'Proxy', 'Intl',
'undefined', 'null', 'true', 'false', 'NaN', 'Infinity', 'arguments', 'this', 'super',
'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'AbortController', 'AbortSignal',
'fetch', 'Request', 'Response', 'Headers', 'FormData', 'Blob', 'File', 'structuredClone',
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'queueMicrotask',
'crypto', 'Buffer', 'atob', 'btoa', 'performance', 'navigator', 'window', 'document',
'localStorage', 'sessionStorage', 'location', 'history', 'alert', 'require', 'module',
'exports', '__dirname', '__filename', 'import', 'meta', 'await', 'async', 'yield',
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue',
'return', 'function', 'class', 'const', 'let', 'var', 'new', 'delete', 'typeof',
'instanceof', 'in', 'of', 'void', 'try', 'catch', 'finally', 'throw', 'extends', 'static',
'get', 'set', 'from', 'as', 'export',
]);
// Tag JSX huruf kecil. Dipakai untuk membedakan <html lang="en"> dari
// pembanding `a < b`. Nama variabel di kode latihan selalu panjang/bercampur,
// jadi daftar pendek ini cukup dan tidak menimbulkan positif palsu.
const TAG_JSX = new Set([
'html', 'head', 'body', 'main', 'div', 'span', 'p', 'a', 'ul', 'ol', 'li',
'section', 'header', 'footer', 'nav', 'form', 'label', 'button', 'input',
'table', 'thead', 'tbody', 'tr', 'td', 'th', 'br', 'hr', 'img', 'svg', 'path',
]);
// Mencari karakter bermakna sebelumnya, melompati spasi, tab, dan newline
// sekaligus melaporkan apakah spasi tersebut memuat newline.
function sebelumDanBarisBaru(asal, mulai) {
let j = mulai - 1;
let adaBarisBaru = false;
while (j >= 0 && /\s/.test(asal[j])) {
if (asal[j] === '\n') adaBarisBaru = true;
j--;
}
return { karakter: j >= 0 ? asal[j] : '', adaBarisBaru };
}
// Memeriksa satu posisi: apakah karakter pada posisi itu berada di dalam
// komentar atau literal string? Dipakai untuk memfilter hasil pencocokan
// tanpa menulis ulang teks (menulis ulang akan menggeser indeks dan membuat
// nomor baris temuan jadi salah).
function buatMasker(asal) {
const masker = new Uint8Array(asal.length); // 1 = abaikan
let i = 0;
while (i < asal.length) {
const c = asal[i];
const d = asal[i + 1];
if (c === '/' && d === '/') {
while (i < asal.length && asal[i] !== '\n') masker[i++] = 1;
continue;
}
if (c === '/' && d === '*') {
masker[i++] = 1;
masker[i++] = 1;
while (i < asal.length && !(asal[i] === '*' && asal[i + 1] === '/')) masker[i++] = 1;
if (i < asal.length) { masker[i++] = 1; masker[i++] = 1; }
continue;
}
if (c === '"' || c === "'" || c === '\u0060') {
const kutip = c;
masker[i++] = 1;
while (i < asal.length) {
if (asal[i] === '\\') { masker[i++] = 1; if (i < asal.length) masker[i++] = 1; continue; }
if (asal[i] === kutip) { masker[i++] = 1; break; }
if (asal[i] !== '\n') masker[i] = 1;
i++;
}
continue;
}
i++;
}
return masker;
}
// Identifier yang bukan variabel: nama properti (obj.foo), nama kunci objek,
// nama atribut JSX, dan nama tag JSX.
function identifierDipakai(asal, masker) {
const hasil = [];
const pola = /(?<![.\w$])([A-Za-z_$][\w$]*)/g;
let m;
while ((m = pola.exec(asal)) !== null) {
const nama = m[1];
const mulai = m.index;
const selesai = pola.lastIndex;
if (masker[mulai]) continue;
const { karakter: sebelum, adaBarisBaru } = sebelumDanBarisBaru(asal, mulai);
// obj.foo -> nama properti
if (sebelum === '.') continue;
// <html lang="en"> -> nama tag JSX
if (TAG_JSX.has(nama) && sebelum === '<') continue;
// </html> -> karakter sebelum nama adalah '/'
if (TAG_JSX.has(nama) && sebelum === '/') {
const sebelum2 = sebelumDanBarisBaru(asal, mulai - 1).karakter;
if (sebelum2 === '<') continue;
}
let k = selesai;
while (k < asal.length && /[ \t]/.test(asal[k])) k++;
const sesudah = asal[k] ?? '';
const sesudah2 = asal[k + 1] ?? '';
// nama atribut JSX: lang= , href= , dst.
if (sesudah === '=' && !['=', '!', '<', '>', '+', '-', '*', '/', '%', '&', '|', '?'].includes(sebelum)) {
continue;
}
// Nama kunci objek. Hanya dianggap kunci kalau berada di awal baris atau
// didahului '{' atau ',' — sehingga `a ? b : c` dan `x = y ? 1 : 2` tetap
// dihitung sebagai pemakaian variabel.
if (sesudah === ':' && sesudah2 !== ':' && sesudah2 !== '=') {
const kunciObjek = sebelum === '{' || sebelum === ',' || sebelum === '' || adaBarisBaru;
if (kunciObjek) continue;
}
hasil.push({ nama, indeks: mulai });
}
return hasil;
}
// Mengambil semua nama yang diperkenalkan oleh sebuah pola destructuring,
// termasuk bentuk bersarang dan nilai default. Dipakai untuk
// `const { a, b: { c }, d = 1 } = ...` dan untuk daftar parameter fungsi.
function namaDariPola(pola) {
const nama = new Set();
const teks = pola.trim();
if (!teks) return nama;
function prosesAnggota(anggota) {
const bersih = anggota.trim();
if (!bersih) return;
const pisah = bersih.indexOf(':');
if (pisah === -1) {
// { a } atau { a = 1 } atau ...sisa
const tanpaDefault = bersih.split('=')[0].trim().replace(/^\.\.\./, '');
if (/^[A-Za-z_$][\w$]*$/.test(tanpaDefault)) nama.add(tanpaDefault);
return;
}
const nilai = bersih.slice(pisah + 1).trim();
if (nilai.startsWith('{') || nilai.startsWith('[')) {
for (const n of namaDariPola(nilai)) nama.add(n);
} else {
// { a: b } atau { a: b = 1 } -> yang diperkenalkan adalah b
const target = nilai.split('=')[0].trim().replace(/^\.\.\./, '');
if (/^[A-Za-z_$][\w$]*$/.test(target)) nama.add(target);
}
}
if (teks.startsWith('{')) {
const dalam = teks.slice(1, teks.lastIndexOf('}'));
let kedalaman = 0;
let potongan = '';
for (const c of dalam) {
if ('{[('.includes(c)) kedalaman++;
if ('}])'.includes(c)) kedalaman--;
if (c === ',' && kedalaman === 0) { prosesAnggota(potongan); potongan = ''; continue; }
potongan += c;
}
prosesAnggota(potongan);
} else if (teks.startsWith('[')) {
const dalam = teks.slice(1, teks.lastIndexOf(']'));
for (const bagian of pisahParam(dalam)) {
for (const n of namaDariPola(bagian)) nama.add(n);
}
} else {
// Parameter biasa: `request`, `c = 1`, `...sisa`.
const m = teks.match(/^\s*(\.\.\.)?\s*([A-Za-z_$][\w$]*)/);
if (m) nama.add(m[2]);
}
return nama;
}
// Memisahkan daftar parameter pada koma tingkat atas saja, sehingga
// `{ a, b }, c` tetap menjadi dua potongan, bukan tiga.
function pisahParam(teks) {
const hasil = [];
let kedalaman = 0;
let potongan = '';
for (const c of teks) {
if ('([{'.includes(c)) kedalaman++;
else if (')]}'.includes(c)) kedalaman--;
if (c === ',' && kedalaman === 0) { hasil.push(potongan); potongan = ''; continue; }
potongan += c;
}
hasil.push(potongan);
return hasil.filter((p) => p.trim() !== '');
}
function identifierDideklarasikan(kodeBersih) {
const nama = new Set();
let m;
// import ... from "..."
const polaImport = /import\s+([\s\S]*?)\s+from\s+/g;
while ((m = polaImport.exec(kodeBersih)) !== null) {
const klausa = m[1];
const defaultImport = klausa.match(/^\s*([A-Za-z_$][\w$]*)/);
if (defaultImport) nama.add(defaultImport[1]);
const dalamKurung = klausa.match(/\{([\s\S]*?)\}/);
if (dalamKurung) {
for (const bagian of dalamKurung[1].split(',')) {
const bersih = bagian.trim();
if (!bersih) continue;
const alias = bersih.split(/\s+as\s+/);
nama.add((alias[1] ?? alias[0]).trim());
}
}
const namespace = klausa.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
if (namespace) nama.add(namespace[1]);
}
// const/let/var, termasuk destructuring: const { a, b } = ...
const polaVar = /\b(?:const|let|var)\s+/g;
while ((m = polaVar.exec(kodeBersih)) !== null) {
const sisa = kodeBersih.slice(m.index + m[0].length);
if (sisa.startsWith('{') || sisa.startsWith('[')) {
const pembuka = sisa[0];
const penutup = pembuka === '{' ? '}' : ']';
let kedalaman = 0;
let akhir = -1;
for (let i = 0; i < sisa.length; i++) {
if (sisa[i] === pembuka) kedalaman++;
else if (sisa[i] === penutup) {
kedalaman--;
if (kedalaman === 0) { akhir = i; break; }
}
}
if (akhir > -1) for (const n of namaDariPola(sisa.slice(0, akhir + 1))) nama.add(n);
} else {
// deklarasi berantai: const a = 1, b = 2;
const sampaiTitikKoma = sisa.split(';')[0];
let kedalaman = 0;
let potongan = '';
const kandidat = [];
for (const c of sampaiTitikKoma) {
if ('([{'.includes(c)) kedalaman++;
if (')]}'.includes(c)) kedalaman--;
if (c === ',' && kedalaman === 0) { kandidat.push(potongan); potongan = ''; continue; }
potongan += c;
}
kandidat.push(potongan);
for (const k of kandidat) {
const bersih = k.split('=')[0].trim().replace(/^\.{3}/, '');
if (/^[A-Za-z_$][\w$]*$/.test(bersih)) nama.add(bersih);
}
}
}
// function nama(a, b) {}
// Termasuk `export async function POST(request)`. Daftar parameter diambil
// dengan menghitung tanda kurung, bukan regex, supaya parameter
// destructuring seperti ({ a, b }) tidak memotong di kurung pertama.
for (const f of kodeBersih.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
nama.add(f[1]);
const awal = f.index + f[0].length;
let kedalaman = 1;
let akhir = -1;
for (let i = awal; i < kodeBersih.length; i++) {
if (kodeBersih[i] === '(') kedalaman++;
else if (kodeBersih[i] === ')') {
kedalaman--;
if (kedalaman === 0) { akhir = i; break; }
}
}
if (akhir === -1) continue;
for (const p of pisahParam(kodeBersih.slice(awal, akhir))) {
for (const n of namaDariPola(p)) nama.add(n);
}
}
// (a, b) => ... dan x => ...
for (const f of kodeBersih.matchAll(/\)\s*=>/g)) {
// telusuri mundur untuk menemukan kurung pembuka yang berpasangan
let kedalaman = 1;
let i = f.index - 1;
for (; i >= 0; i--) {
if (kodeBersih[i] === ')') kedalaman++;
else if (kodeBersih[i] === '(') {
kedalaman--;
if (kedalaman === 0) break;
}
}
if (i < 0) continue;
// lewati nama fungsi bila bentuknya `function nama(...)`
const sebelum = kodeBersih.slice(0, i).trimEnd();
if (/\bfunction\s+[A-Za-z_$][\w$]*$/.test(sebelum) || /\bfunction$/.test(sebelum)) continue;
for (const p of pisahParam(kodeBersih.slice(i + 1, f.index))) {
for (const n of namaDariPola(p)) nama.add(n);
}
}
for (const f of kodeBersih.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) nama.add(f[1]);
// catch (e) dan catch { }
for (const f of kodeBersih.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)/g)) nama.add(f[1]);
// Method pada objek literal: `async jwt({ token, user }) { ... }` dan
// `session({ session, token }) { ... }`. Nama parameter di dalamnya
// dideklarasikan oleh tanda tangan method tersebut.
//
// Bentuk ini harus dibedakan dari PEMANGGILAN fungsi seperti
// `verifyAccessToken(token.accessToken)` atau `if (error)`. Caranya:
// setelah kurung tutup harus ada '{' (badan fungsi), bukan ';' atau ','.
for (const m of kodeBersih.matchAll(/(?:^|[{,])\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
const awal = m.index + m[0].length;
let kedalaman = 1;
let akhir = -1;
for (let i = awal; i < kodeBersih.length; i++) {
if (kodeBersih[i] === '(') kedalaman++;
else if (kodeBersih[i] === ')') {
kedalaman--;
if (kedalaman === 0) { akhir = i; break; }
}
}
if (akhir === -1) continue;
let t = akhir + 1;
while (t < kodeBersih.length && /[ \t]/.test(kodeBersih[t])) t++;
if (kodeBersih[t] !== '{') continue; // ini pemanggilan, bukan deklarasi
nama.add(m[1]);
for (const param of pisahParam(kodeBersih.slice(awal, akhir))) {
for (const n of namaDariPola(param)) nama.add(n);
}
}
// for (const x of y) sudah tertangani oleh polaVar.
// class Foo {} dan export default function Foo
for (const f of kodeBersih.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)/g)) nama.add(f[1]);
return nama;
}
// Mengumpulkan nama yang muncul sebagai KUNCI di dalam objek literal.
// Dipakai untuk mencegah dua jenis positif palsu:
// { session: "jwt" } -> "session" bukan referensi variabel
// { async jwt(...) { } } -> "jwt" adalah nama method
// { handler as GET } -> "GET" adalah nama ekspor
//
// Bedanya dengan pendekatan berbasis regex sebelumnya: di sini struktur
// objek benar-benar ditelusuri. Nilai sebuah kunci (termasuk pemanggilan
// fungsi seperti `verifyAccessToken(...)` yang berdiri sendiri di satu baris)
// TIDAK ikut dianggap kunci, sehingga bug yang sebenarnya tidak ikut hilang.
function kunciObjekDari(asal) {
const kunci = new Set();
const tumpukan = []; // 'obj' bila '{' membuka objek, 'blok' bila sebaliknya
for (let i = 0; i < asal.length; i++) {
const c = asal[i];
if (c === '{') {
let j = i - 1;
while (j >= 0 && /\s/.test(asal[j])) j--;
const sebelum = j >= 0 ? asal[j] : '';
let jenis = 'blok';
// '{' membuka objek bila didahului '(', '=', ',', ':', '[' atau 'return'
if ('(=[:, '.includes(sebelum) && sebelum.trim() !== '') jenis = 'obj';
if (sebelum === '') jenis = 'obj';
if (/(^|\s)(return|typeof|await|case)$/.test(asal.slice(Math.max(0, j - 8), j + 1))) jenis = 'obj';
tumpukan.push(jenis);
continue;
}
if (c === '}') {
tumpukan.pop();
continue;
}
if (tumpukan[tumpukan.length - 1] !== 'obj') continue;
// Di dalam objek: kunci hanya sah pada kedalaman paling atas objek
// tersebut, dan hanya bila kita baru saja melewati '{' atau ','.
let k = i;
while (k < asal.length && /[ \t\n]/.test(asal[k])) k++;
if (k !== i) { i = k - 1; }
const m = /^([A-Za-z_$][\w$]*)/.exec(asal.slice(k));
if (!m) continue;
const nama = m[1];
let t = k + nama.length;
while (t < asal.length && /[ \t]/.test(asal[t])) t++;
// { handler as GET }
const mAs = /^as[ \t]+([A-Za-z_$][\w$]*)/.exec(asal.slice(t));
if (mAs) {
kunci.add(mAs[1]);
i = t + mAs[0].length - 1;
continue;
}
const sesudah = asal[t];
const sesudah2 = asal[t + 1];
if (sesudah === ':' && sesudah2 !== ':' && sesudah2 !== '=') {
kunci.add(nama);
} else if (sesudah === '(') {
// Bisa method pada objek, bisa juga pemanggilan fungsi biasa.
// Method punya badan: setelah ')' berpasangan ada '{'.
let kedalaman = 1;
let u = t + 1;
for (; u < asal.length; u++) {
if (asal[u] === '(') kedalaman++;
else if (asal[u] === ')') { kedalaman--; if (kedalaman === 0) break; }
}
let v = u + 1;
while (v < asal.length && /[ \t]/.test(asal[v])) v++;
if (asal[v] === '{') {
kunci.add(nama);
i = v - 1;
continue;
}
}
// Kunci ringkas: { token, user } -> penanda variabel, bukan referensi bebas
if (sesudah === ',' || sesudah === '}') kunci.add(nama);
i = t - 1;
}
return kunci;
}
// `export { handler as GET, handler as POST }` bukan objek literal, melainkan
// daftar nama ekspor. Nama di sisi kiri `as` adalah variabel, nama di sisi
// kanan adalah nama ekspor — keduanya tidak boleh dianggap referensi bebas.
function namaEksporSpecifier(asal) {
const nama = new Set();
for (const m of asal.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const bagian of m[1].split(',')) {
const pisah = bagian.split(/\s+as\s+/);
const kiri = pisah[0].trim();
if (/^[A-Za-z_$][\w$]*$/.test(kiri)) nama.add(kiri);
if (pisah[1] && /^[A-Za-z_$][\w$]*$/.test(pisah[1].trim())) nama.add(pisah[1].trim());
}
}
return nama;
}
// ---------------------------------------------------------------- audit
const temuan = [];
const berkas = cariBerkasJs(target).sort();
function tambah(severity, berkasNama, baris, judul, detail) {
temuan.push({
severity,
berkas: relative(target, berkasNama).split('\\').join('/'),
baris,
judul,
detail: detail ?? '',
});
}
// Identifier yang diimpor dari modul lokal: dipakai untuk memeriksa apakah
// yang dipakai benar-benar diekspor oleh berkas sumbernya.
const eksporPerBerkas = {};
for (const jalurBerkas of berkas) {
const teks = readFileSync(jalurBerkas, 'utf8');
const kode = bersihkan(teks); // dipakai untuk daftar export dan analisis lain
const nama = basename(jalurBerkas);
// `asal` = sumber apa adanya: komentar utuh dan tanda kutip belum dibuang.
// Pemeriksaan pola seperti searchParams.get("access_token") selalu meleset
// bila dijalankan pada `kode`, karena isi string sudah dihapus bersihkan().
const asal = teks.replace(/\r\n?/g, '\n');
eksporPerBerkas[jalurBerkas] = new Set(
[...kode.matchAll(/export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]),
);
// --- 1. identifier tak terdefinisi -------------------------------
const kunciObjek = kunciObjekDari(asal);
for (const n of namaEksporSpecifier(asal)) kunciObjek.add(n);
const dideklarasikan = identifierDideklarasikan(kode);
const dipakai = identifierDipakai(asal, buatMasker(asal));
const dilaporkan = new Set();
for (const { nama: id, indeks } of dipakai) {
if (dideklarasikan.has(id) || GLOBAL_DIKENAL.has(id) || kunciObjek.has(id)) continue;
if (dilaporkan.has(id)) continue;
dilaporkan.add(id);
tambah(
'kritis',
jalurBerkas,
nomorBaris(kode, indeks),
`"${id}" dipakai tanpa di-import atau dideklarasikan`,
'Saat dijalankan ini melempar ReferenceError. Impor namanya dari lib/auth, atau hapus pemakaiannya.',
);
}
// --- 2. token lewat query string ----------------------------------
if (/searchParams\.get\(\s*["']access_token["']\s*\)/.test(asal)) {
tambah(
'tinggi',
jalurBerkas,
nomorBaris(asal, asal.indexOf('access_token')),
'Token diterima dari query string (?access_token=...)',
'Query string ikut tercatat di access log Nginx, log Cloud Build, history browser, dan header Referer.\n' +
'Hapus jalur ini; cukup pakai header Authorization: Bearer.',
);
}
// --- 3. kolom sensitif ikut terkirim ------------------------------
// Blok select bisa ditulis satu baris (`select: { a: true, b: true }`)
// atau satu kolom per baris. Keduanya harus terbaca, jadi blok diambil
// dengan mencocokkan kurung kurawal, bukan per baris.
for (const m of asal.matchAll(/select\s*:\s*\{/g)) {
const awalBlok = m.index + m[0].length - 1;
let kedalaman = 0;
let akhirBlok = -1;
for (let i = awalBlok; i < asal.length; i++) {
if (asal[i] === '{') kedalaman++;
else if (asal[i] === '}') {
kedalaman--;
if (kedalaman === 0) { akhirBlok = i; break; }
}
}
if (akhirBlok === -1) continue;
const blok = asal.slice(awalBlok, akhirBlok + 1);
if (/\bpassword\b/.test(blok)) {
tambah('kritis', jalurBerkas, nomorBaris(asal, awalBlok),
'Klausa select menyertakan kolom password',
'Hash bcrypt ikut terkirim ke pemanggil API.\n' +
blok.split('\n').map((b) => b.trim()).join(' ').slice(0, 160));
}
}
// findMany/findFirst/findUnique tanpa klausa select -> seluruh kolom termasuk password
const tanpaSelect = /db\.users\.(findMany|findFirst|findUnique)\s*\(\s*\{[^}]*\}\s*\)/s.test(asal)
&& !/select\s*:/.test(asal);
if (tanpaSelect) {
tambah('tinggi', jalurBerkas, 1,
'Query users tanpa klausa select (semua kolom ikut terbaca)',
'Kolom password ikut masuk ke memori dan gampang ikut terserialisasi ke respons.');
}
// respons mengembalikan hasil create secara utuh
if (/data\s*:\s*newUser\b/.test(asal)) {
tambah('tinggi', jalurBerkas, nomorBaris(asal, asal.indexOf('newUser')),
'Respons create mengembalikan objek user apa adanya',
'Objek hasil prisma create memuat kolom password berisi hash bcrypt.');
}
// --- 4. kunci objek ganda -----------------------------------------
const blokData = asal.match(/data\s*:\s*\{([\s\S]*?)\n\s*\}/);
if (blokData) {
const kunci = [...blokData[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]);
const ganda = kunci.filter((k, i) => kunci.indexOf(k) !== i);
for (const k of new Set(ganda)) {
tambah('sedang', jalurBerkas, nomorBaris(asal, asal.indexOf(blokData[0])),
`Kunci "${k}" ditulis dua kali dalam satu objek`,
'Nilai yang kedua menimpa yang pertama tanpa peringatan apa pun.');
}
}
// --- 5. endpoint tanpa otorisasi ----------------------------------
const punyaHandler = /export\s+async\s+function\s+(GET|POST|PUT|PATCH|DELETE)/.test(asal);
const pakaiAuth = /requireAuth\s*\(/.test(asal);
const publikDisengaja = /register|login/.test(jalurBerkas);
if (punyaHandler && !pakaiAuth && !publikDisengaja) {
tambah('kritis', jalurBerkas, 1, 'Endpoint tidak memanggil requireAuth',
'Siapa pun yang bisa menjangkau URL ini bisa membaca atau mengubah data.');
}
// --- 6. pesan error database bocor ---------------------------------
if (/message\s*:\s*err(?:or)?\.message/.test(asal)) {
tambah('sedang', jalurBerkas, nomorBaris(asal, asal.search(/message\s*:\s*err(?:or)?\.message/)),
'Pesan error database diteruskan mentah ke klien',
'Kebocoran informasi: nama tabel, kolom, sampai potongan query bisa terbaca penyerang.');
}
// --- 7. secret dengan nilai default --------------------------------
if (/process\.env\.[A-Z_]+\s*(\|\||\?\?)\s*["'][^"']+["']/.test(asal)) {
const m = asal.match(/process\.env\.([A-Z_]+)\s*(\|\||\?\?)\s*["']([^"']+)["']/);
tambah('tinggi', jalurBerkas, nomorBaris(asal, m.index),
`process.env.${m[1]} punya nilai default "${m[3]}"`,
'Kalau variabel .env lupa diisi, aplikasi tetap jalan memakai nilai default yang ikut ter-commit ke Git.\n' +
'Lebih baik gagal saat start daripada diam-diam memakai nilai contoh.',
);
}
// --- 8. urutan pemeriksaan yang bocor ------------------------------
// requireAuth dipanggil setelah await request.json()
const idxAuth = asal.indexOf('requireAuth(');
const idxJson = asal.indexOf('await request.json()');
if (idxAuth > -1 && idxJson > -1 && idxJson < idxAuth) {
tambah('rendah', jalurBerkas, nomorBaris(asal, idxJson),
'Body request dibaca sebelum token diperiksa',
'Beban parsing JSON bisa dipicu tanpa autentikasi. Pindahkan requireAuth ke baris pertama.',
);
}
// --- 9. kata sandi di-hash dengan cost terlalu rendah --------------
const mCost = asal.match(/bcrypt\.(?:hash|hashSync)\s*\(([^)]*?),\s*(\d+)\s*\)/);
if (mCost && Number(mCost[2]) < 10) {
tambah('tinggi', jalurBerkas, nomorBaris(asal, mCost.index),
`Cost bcrypt hanya ${mCost[2]}`, 'Minimum yang lazim adalah 10, disarankan 12.');
}
// --- 10. hashSync pada jalur request -------------------------------
if (/bcrypt\.hashSync\s*\(/.test(asal) && punyaHandler) {
tambah('sedang', jalurBerkas, nomorBaris(asal, asal.indexOf('hashSync')),
'bcrypt.hashSync dipakai di dalam request handler',
'Versi sinkron memblokir event loop Node selama proses hashing. Pakai await bcrypt.hash().');
}
// --- 11. DELETE memakai method POST --------------------------------
if (/export\s+async\s+function\s+POST/.test(asal) && /\.delete\s*\(\s*\{/.test(asal)) {
tambah('rendah', jalurBerkas, 1, 'Penghapusan data lewat POST, bukan DELETE',
'Bukan bug, tapi melanggar konvensi REST dan menyulitkan pembatasan akses di tingkat proxy.');
}
// --- 12. operasi berisiko tanpa try/catch --------------------------
if (punyaHandler && !/\btry\s*\{/.test(asal) && /await db\./.test(asal)) {
tambah('sedang', jalurBerkas, 1, 'Operasi database tidak dibungkus try/catch',
'Kegagalan database akan menghasilkan 500 tanpa respons JSON yang bisa dibaca klien.');
}
}
// --- 13. impor dari berkas lokal: pastikan nama benar-benar diekspor ---
for (const jalurBerkas of berkas) {
const teks = readFileSync(jalurBerkas, 'utf8');
for (const m of teks.matchAll(/import\s*\{([^}]+)\}\s*from\s*["'](\.[^"']+)["']/g)) {
const diminta = m[1].split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
const tujuan = m[2];
const namaTujuan = basename(tujuan);
const kandidat = berkas.filter((b) => {
const dasar = basename(b).replace(/\.(js|mjs|jsx)$/, '');
return dasar === namaTujuan.replace(/\.(js|mjs|jsx)$/, '');
});
if (kandidat.length !== 1) continue; // impor ke folder ../db, tidak bisa dipastikan
const tersedia = eksporPerBerkas[kandidat[0]] ?? new Set();
for (const nama of diminta) {
if (tersedia.size > 0 && !tersedia.has(nama)) {
tambah('kritis', jalurBerkas, nomorBaris(teks, m.index),
`"${nama}" diimpor dari ${tujuan} tetapi tidak diekspor di sana`,
`Yang diekspor oleh ${basename(kandidat[0])}: ${[...tersedia].join(', ') || '(tidak ada)'}`);
}
}
}
}
// ---------------------------------------------------------------- hasil
if (keluaranJson) {
console.log(JSON.stringify({ target, jumlahBerkas: berkas.length, temuan }, null, 2));
process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);
}
const WARNA = { kritis: '\u001b[31m', tinggi: '\u001b[31m', sedang: '\u001b[33m', rendah: '\u001b[36m', info: '\u001b[2m' };
const URUT = { kritis: 0, tinggi: 1, sedang: 2, rendah: 3, info: 4 };
const R = '\u001b[0m';
const w = (s, t) => (process.stdout.isTTY ? WARNA[s] + t + R : t);
console.log(`Folder : ${target}`);
console.log(`Berkas : ${berkas.length} (${berkas.map((b) => relative(target, b)).join(', ')})`);
console.log('');
if (temuan.length === 0) {
console.log('\u001b[32mTidak ada temuan.\u001b[0m');
process.exit(0);
}
const urut = [...temuan].sort(
(a, b) => URUT[a.severity] - URUT[b.severity] || a.berkas.localeCompare(b.berkas) || a.baris - b.baris,
);
for (const t of urut) {
console.log(`${w(t.severity, '[' + t.severity.toUpperCase() + ']')} ${t.berkas}:${t.baris} — ${t.judul}`);
if (t.detail) for (const b of t.detail.split('\n')) console.log(` ${b}`);
}
const ringkas = {};
for (const t of temuan) ringkas[t.severity] = (ringkas[t.severity] ?? 0) + 1;
console.log('');
console.log('Ringkasan: ' + Object.entries(ringkas).sort((a, b) => URUT[a[0]] - URUT[b[0]]).map(([s, n]) => `${s.toUpperCase()}=${n}`).join(' '));
process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);