Audit statis API
Audit statis API
Section titled “Audit statis API”Menangkap impor hilang, token di query string, dan kolom bocor.
Berkas: alat/hari-3/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';
// ---------------------------------------------------------------- argumenconst 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 JSfunction 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;}
// ---------------------------------------------------------------- utilfunction 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;}
// ---------------------------------------------------------------- auditconst 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)'}`); } } }}
// ---------------------------------------------------------------- hasilif (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);