qaify-playwright-reporter 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/audit-api.d.ts +118 -0
- package/dist/audit-api.js +628 -0
- package/dist/audit-static.d.ts +15 -6
- package/dist/audit-static.js +101 -80
- package/dist/cli.js +19 -3
- package/dist/fix.d.ts +25 -1
- package/dist/fix.js +112 -14
- package/package.json +1 -1
- package/templates/qaify-audit.yml +1 -1
- package/templates/qaify-fix.yml +12 -3
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// Pemeriksaan API tanpa AI untuk audit-static: menguji BE yang sudah berjalan lewat HTTP, dan mencocokkan
|
|
2
|
+
// pemanggilan API di kode FE dengan route BE. Semua deterministik; tidak ada yang menebak aturan bisnis.
|
|
3
|
+
// smokeApi: login dengan akun dari catatan aplikasi, panggil route GET (termasuk yang terlindungi dan
|
|
4
|
+
// ber-{param}, diisi ID dari route index-nya) → respons ≥500.
|
|
5
|
+
// checkValidation: POST/PUT/PATCH dengan body kosong dan nilai yang melanggar aturan validasi → 500 / diterima.
|
|
6
|
+
// checkAuthorization: route API pengubah data tanpa auth, data yang terbaca tanpa login, akses antar-role.
|
|
7
|
+
// checkContract: axios/fetch di src/ FE → route BE (path tidak ada / method beda).
|
|
8
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
9
|
+
import { join, relative } from 'node:path';
|
|
10
|
+
import { controllerLocation, lineAt, listSources, parseJsonLoose, tail } from './audit-static.js';
|
|
11
|
+
const MAX_API_ROUTES = 80;
|
|
12
|
+
const MAX_WRITE_ROUTES = 40;
|
|
13
|
+
const MAX_ROLE_ROUTES = 20;
|
|
14
|
+
const MAX_ACCOUNTS = 4;
|
|
15
|
+
/** Temuan per jenis pemeriksaan, agar satu jenis tidak memenuhi laporan. */
|
|
16
|
+
const MAX_PER_CHECK = 10;
|
|
17
|
+
const WRITE_METHODS = ['POST', 'PUT', 'PATCH'];
|
|
18
|
+
const IGNORED = /^\/?(_|telescope|horizon|pulse|livewire|storage)/;
|
|
19
|
+
/** Route alur autentikasi: tidak diuji validasi, tidak dianggap bocor bila publik. */
|
|
20
|
+
const AUTH_FLOW = /(^|\/)(login|logout|signin|sign-in|signup|sign-up|register|refresh|token|forgot-password|reset-password|password|email\/verify|verify-email|two-factor|csrf-cookie)(\/|$)/i;
|
|
21
|
+
const LOGIN_URI = /(^|\/)(login|signin|sign-in|token)$/i;
|
|
22
|
+
// ---------------------------------------------------------------- route & akun
|
|
23
|
+
/** `route:list --json` → route; HEAD dibuang dari method. */
|
|
24
|
+
export function parseRoutes(output) {
|
|
25
|
+
const routes = parseJsonLoose(output);
|
|
26
|
+
if (!Array.isArray(routes))
|
|
27
|
+
return [];
|
|
28
|
+
return routes
|
|
29
|
+
.filter((r) => typeof r?.uri === 'string')
|
|
30
|
+
.map((r) => ({
|
|
31
|
+
methods: String(r.method ?? '').split('|').filter((m) => m && m !== 'HEAD'),
|
|
32
|
+
uri: String(r.uri).replace(/^\/+/, ''),
|
|
33
|
+
action: typeof r.action === 'string' && r.action !== 'Closure' ? r.action : null,
|
|
34
|
+
middleware: Array.isArray(r.middleware) ? r.middleware.map(String) : [],
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
const middlewareName = (m) => m.split('\\').pop() ?? m;
|
|
38
|
+
export const isProtected = (route) => route.middleware.some((m) => /^(auth|verified|signed|password\.confirm)(:|$)/.test(middlewareName(m)) || /Authenticate/.test(m));
|
|
39
|
+
/** Role yang boleh mengakses route dari middleware role:admin|manager, CheckRole:admin, isAdmin; null = tanpa batas role. */
|
|
40
|
+
export function allowedRoles(route) {
|
|
41
|
+
for (const m of route.middleware.map(middlewareName)) {
|
|
42
|
+
const param = /^(?:role|roles|hasrole|checkrole|check[._]role|role_or_permission)[:]([\w|,.-]+)$/i.exec(m);
|
|
43
|
+
if (param)
|
|
44
|
+
return param[1].split(/[|,]/).map((r) => r.toLowerCase()).filter(Boolean);
|
|
45
|
+
if (/^(is_?admin|admin(_?only)?)$/i.test(m))
|
|
46
|
+
return ['admin'];
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** Akun dari catatan aplikasi, mis. "admin: admin@pos.test / rahasia; kasir: kasir@pos.test / kasir123". */
|
|
51
|
+
export function parseLogins(instructions) {
|
|
52
|
+
const accounts = [];
|
|
53
|
+
for (const m of instructions.matchAll(/(?:([A-Za-z][\w-]*)\s*[:=]\s*)?([\w.+-]+@[\w-]+(?:\.[\w-]+)+)\s*\/\s*(\S+)/g)) {
|
|
54
|
+
const label = m[1] && !/^(login|akun|user|email|username|pengguna|kredensial)$/i.test(m[1]) ? m[1].toLowerCase() : null;
|
|
55
|
+
accounts.push({ label, email: m[2], password: m[3].replace(/[;,]$/, '') });
|
|
56
|
+
}
|
|
57
|
+
return accounts;
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------- HTTP
|
|
60
|
+
async function send(probe, method, uri, session, body) {
|
|
61
|
+
const headers = { Accept: 'application/json', ...session?.headers };
|
|
62
|
+
if (session?.cookies.size) {
|
|
63
|
+
headers.Cookie = [...session.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
64
|
+
const xsrf = session.cookies.get('XSRF-TOKEN');
|
|
65
|
+
if (xsrf)
|
|
66
|
+
headers['X-XSRF-TOKEN'] = decodeURIComponent(xsrf);
|
|
67
|
+
}
|
|
68
|
+
if (body !== undefined)
|
|
69
|
+
headers['Content-Type'] = 'application/json';
|
|
70
|
+
const res = await probe
|
|
71
|
+
.fetch(`${probe.base}/${uri.replace(/^\/+/, '')}`, {
|
|
72
|
+
method,
|
|
73
|
+
headers,
|
|
74
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
75
|
+
redirect: 'manual',
|
|
76
|
+
signal: AbortSignal.timeout(10_000),
|
|
77
|
+
})
|
|
78
|
+
.catch(() => null);
|
|
79
|
+
if (!res)
|
|
80
|
+
return null;
|
|
81
|
+
if (session) {
|
|
82
|
+
for (const cookie of res.headers?.getSetCookie?.() ?? []) {
|
|
83
|
+
const [pair] = cookie.split(';');
|
|
84
|
+
const at = pair.indexOf('=');
|
|
85
|
+
if (at > 0)
|
|
86
|
+
session.cookies.set(pair.slice(0, at).trim(), pair.slice(at + 1).trim());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const text = await res.text().catch(() => '');
|
|
90
|
+
let json = null;
|
|
91
|
+
try {
|
|
92
|
+
json = JSON.parse(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// bukan JSON
|
|
96
|
+
}
|
|
97
|
+
return { status: res.status, text, json };
|
|
98
|
+
}
|
|
99
|
+
/** Token API di respons login (Sanctum / Passport / JWT), di kedalaman mana pun. */
|
|
100
|
+
export function findToken(json, depth = 0) {
|
|
101
|
+
if (!json || typeof json !== 'object' || depth > 4)
|
|
102
|
+
return null;
|
|
103
|
+
const obj = json;
|
|
104
|
+
for (const key of ['token', 'access_token', 'accessToken', 'plainTextToken']) {
|
|
105
|
+
if (typeof obj[key] === 'string' && obj[key].length >= 10)
|
|
106
|
+
return obj[key];
|
|
107
|
+
}
|
|
108
|
+
for (const value of Object.values(obj)) {
|
|
109
|
+
const token = findToken(value, depth + 1);
|
|
110
|
+
if (token)
|
|
111
|
+
return token;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** Login API: token Bearer dari respons, atau sesi cookie Sanctum SPA (csrf-cookie + X-XSRF-TOKEN). */
|
|
116
|
+
export async function apiLogin(probe, account) {
|
|
117
|
+
const candidates = probe.routes
|
|
118
|
+
.filter((r) => r.methods.includes('POST') && LOGIN_URI.test(r.uri))
|
|
119
|
+
.sort((a, b) => Number(b.uri.startsWith('api/')) - Number(a.uri.startsWith('api/')));
|
|
120
|
+
const spa = probe.routes.some((r) => r.uri === 'sanctum/csrf-cookie');
|
|
121
|
+
const body = { email: account.email, password: account.password, device_name: 'qaify' };
|
|
122
|
+
for (const route of candidates.slice(0, 3)) {
|
|
123
|
+
const res = await send(probe, 'POST', route.uri, null, body);
|
|
124
|
+
const token = res && res.status < 300 ? findToken(res.json) : null;
|
|
125
|
+
if (token)
|
|
126
|
+
return { label: account.label, headers: { Authorization: `Bearer ${token}` }, cookies: new Map() };
|
|
127
|
+
if (!spa && res?.status !== 419)
|
|
128
|
+
continue;
|
|
129
|
+
// Origin = BE: 127.0.0.1:8000 termasuk domain stateful bawaan Sanctum
|
|
130
|
+
const session = { label: account.label, headers: { Origin: probe.base, Referer: `${probe.base}/` }, cookies: new Map() };
|
|
131
|
+
await send(probe, 'GET', 'sanctum/csrf-cookie', session);
|
|
132
|
+
const cookie = await send(probe, 'POST', route.uri, session, body);
|
|
133
|
+
if (cookie && cookie.status < 300 && session.cookies.size)
|
|
134
|
+
return session;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** Login semua akun; akun pertama yang berhasil jadi sesi utama. */
|
|
139
|
+
export async function openApi(routes, accounts, opts) {
|
|
140
|
+
const probe = { ...opts, routes, session: null, sessions: [], ids: new Map() };
|
|
141
|
+
if (!accounts.length)
|
|
142
|
+
return { probe, notes: ['Akun login tidak ada di catatan aplikasi: API hanya diuji tanpa login.'] };
|
|
143
|
+
const failed = [];
|
|
144
|
+
for (const account of accounts.slice(0, MAX_ACCOUNTS)) {
|
|
145
|
+
const session = await apiLogin(probe, account);
|
|
146
|
+
if (session)
|
|
147
|
+
probe.sessions.push(session);
|
|
148
|
+
else
|
|
149
|
+
failed.push(account.label ?? account.email);
|
|
150
|
+
}
|
|
151
|
+
probe.session = probe.sessions[0] ?? null;
|
|
152
|
+
const notes = probe.session
|
|
153
|
+
? [`Login API berhasil (${probe.sessions.length} akun)${failed.length ? `, gagal: ${failed.join(', ')}` : ''}.`]
|
|
154
|
+
: ['Login API gagal (route login tidak ditemukan atau akun ditolak): API hanya diuji tanpa login.'];
|
|
155
|
+
return { probe, notes };
|
|
156
|
+
}
|
|
157
|
+
/** ID pertama dari respons index: array, data[], data.data[], items[], results[]. */
|
|
158
|
+
export function firstId(json) {
|
|
159
|
+
const obj = json;
|
|
160
|
+
const lists = [obj, obj?.data, obj?.data?.data, obj?.items, obj?.results];
|
|
161
|
+
for (const list of lists) {
|
|
162
|
+
if (!Array.isArray(list) || !list.length)
|
|
163
|
+
continue;
|
|
164
|
+
const id = list[0]?.id ?? list[0]?.uuid ?? list[0]?.slug;
|
|
165
|
+
if (typeof id === 'string' || typeof id === 'number')
|
|
166
|
+
return id;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* URI route → path yang bisa dipanggil: {param?} dibuang; satu {param} diisi ID pertama dari route index se-prefix
|
|
172
|
+
* (api/produk/{id}/stok → GET api/produk). null = tidak bisa diisi (tidak ada index / data kosong / >1 param).
|
|
173
|
+
*/
|
|
174
|
+
async function fillParams(probe, uri, session) {
|
|
175
|
+
const path = uri.replace(/\/?\{[\w:]+\?\}/g, '');
|
|
176
|
+
const required = path.match(/\{[\w:]+\}/g) ?? [];
|
|
177
|
+
if (!required.length)
|
|
178
|
+
return path;
|
|
179
|
+
if (required.length > 1)
|
|
180
|
+
return null;
|
|
181
|
+
const index = path.slice(0, path.indexOf('{')).replace(/\/$/, '');
|
|
182
|
+
if (!probe.routes.some((r) => r.uri === index && r.methods.includes('GET')))
|
|
183
|
+
return null;
|
|
184
|
+
const key = `${session?.label ?? '-'} ${index}`;
|
|
185
|
+
if (!probe.ids.has(key))
|
|
186
|
+
probe.ids.set(key, firstId((await send(probe, 'GET', index, session))?.json));
|
|
187
|
+
const id = probe.ids.get(key);
|
|
188
|
+
return id === null || id === undefined ? null : path.replace(/\{[\w:]+\}/, encodeURIComponent(String(id)));
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------- smoke API
|
|
191
|
+
/** Route GET (publik, atau semua bila sudah login) → temuan untuk respons ≥500. */
|
|
192
|
+
export async function smokeApi(probe) {
|
|
193
|
+
const targets = probe.routes
|
|
194
|
+
.filter((r) => r.methods.includes('GET') && !IGNORED.test(r.uri) && !/(^|\/)logout$/.test(r.uri))
|
|
195
|
+
.filter((r) => probe.session || !isProtected(r))
|
|
196
|
+
.slice(0, MAX_API_ROUTES);
|
|
197
|
+
const findings = [];
|
|
198
|
+
const failed = [];
|
|
199
|
+
let called = 0;
|
|
200
|
+
let skipped = 0;
|
|
201
|
+
for (const route of targets) {
|
|
202
|
+
const path = await fillParams(probe, route.uri, probe.session);
|
|
203
|
+
if (path === null) {
|
|
204
|
+
skipped++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
called++;
|
|
208
|
+
const res = await send(probe, 'GET', path, probe.session);
|
|
209
|
+
if (!res || res.status < 500 || findings.length >= MAX_PER_CHECK)
|
|
210
|
+
continue;
|
|
211
|
+
const finding = {
|
|
212
|
+
repo: 'be',
|
|
213
|
+
area: 'error_handling',
|
|
214
|
+
severity: 'high',
|
|
215
|
+
title: `GET /${path} membalas ${res.status}`,
|
|
216
|
+
description: `Route GET /${route.uri}${route.action ? ` (${route.action})` : ''} membalas ${res.status} pada database hasil seed${probe.session && isProtected(route) ? ' (dengan login)' : ''}.`,
|
|
217
|
+
...(await locate(probe, route)),
|
|
218
|
+
evidence: { request: `GET ${probe.base}/${path}`, response: tail(res.text, 2000) },
|
|
219
|
+
suggestion: 'Cek laravel.log untuk exception di route ini dan tangani kasus data kosong / parameter yang hilang.',
|
|
220
|
+
};
|
|
221
|
+
findings.push(finding);
|
|
222
|
+
failed.push({ finding, uri: path });
|
|
223
|
+
}
|
|
224
|
+
const notes = [
|
|
225
|
+
`Smoke API: ${called} route GET dipanggil${probe.session ? ' dengan login' : ' tanpa login'}${skipped ? `, ${skipped} route berparameter dilewati (tidak ada data untuk ID-nya)` : ''}.`,
|
|
226
|
+
];
|
|
227
|
+
return { findings, notes, failed };
|
|
228
|
+
}
|
|
229
|
+
const locate = async (probe, route) => (route.action ? await controllerLocation(probe.root, route.action).catch(() => null) : null) ?? {};
|
|
230
|
+
// ---------------------------------------------------------------- validasi
|
|
231
|
+
/** Aturan validasi dari isi rules() / $request->validate([...]): field → token aturan. Field bersarang dilewati. */
|
|
232
|
+
export function parseRules(source) {
|
|
233
|
+
const rules = new Map();
|
|
234
|
+
for (const m of source.matchAll(/(['"])([\w.*]+)\1\s*=>\s*(\[[^\]]*\]|(['"])[^'"]*\4)/g)) {
|
|
235
|
+
const field = m[2];
|
|
236
|
+
if (/[.*]/.test(field))
|
|
237
|
+
continue;
|
|
238
|
+
const value = m[3];
|
|
239
|
+
const tokens = value.startsWith('[') ? [...value.matchAll(/(['"])([^'"]*)\1/g)].map((t) => t[2]) : [value.slice(1, -1)];
|
|
240
|
+
rules.set(field, tokens.flatMap((t) => t.split('|')).map((t) => t.trim()).filter(Boolean));
|
|
241
|
+
}
|
|
242
|
+
return rules;
|
|
243
|
+
}
|
|
244
|
+
/** Satu nilai yang melanggar aturan per field; field tanpa aturan yang bisa dilanggar tidak diisi. */
|
|
245
|
+
export function invalidPayload(rules) {
|
|
246
|
+
const out = {};
|
|
247
|
+
for (const [field, list] of rules) {
|
|
248
|
+
const has = (rule) => list.some((r) => r === rule || r.startsWith(`${rule}:`));
|
|
249
|
+
const param = (rule) => Number(list.find((r) => r.startsWith(`${rule}:`))?.split(':')[1]);
|
|
250
|
+
if (has('integer') || has('numeric') || has('decimal'))
|
|
251
|
+
out[field] = Number.isFinite(param('min')) ? param('min') - 1 : 'abc';
|
|
252
|
+
else if (has('email'))
|
|
253
|
+
out[field] = 'bukan-email';
|
|
254
|
+
else if (has('boolean'))
|
|
255
|
+
out[field] = 'bukan-boolean';
|
|
256
|
+
else if (has('date'))
|
|
257
|
+
out[field] = 'bukan-tanggal';
|
|
258
|
+
else if (has('array'))
|
|
259
|
+
out[field] = 'bukan-array';
|
|
260
|
+
else if (has('in'))
|
|
261
|
+
out[field] = '__qaify_tidak_valid__';
|
|
262
|
+
else if (has('max') && param('max') <= 5000)
|
|
263
|
+
out[field] = 'x'.repeat(param('max') + 1);
|
|
264
|
+
else if (has('exists'))
|
|
265
|
+
out[field] = 999999999;
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Hasil satu request uji validasi:
|
|
271
|
+
* - server_error: ≥500 untuk input yang salah;
|
|
272
|
+
* - accepted: 2xx untuk nilai yang melanggar aturan, atau body kosong padahal ada field required;
|
|
273
|
+
* - unvalidated: 2xx untuk body kosong di action yang membaca input tanpa aturan validasi sama sekali.
|
|
274
|
+
*/
|
|
275
|
+
export function classifyValidation(status, kind, info) {
|
|
276
|
+
if (status >= 500)
|
|
277
|
+
return 'server_error';
|
|
278
|
+
if (status < 200 || status >= 300)
|
|
279
|
+
return null;
|
|
280
|
+
if (kind === 'invalid' || info.hasRequired)
|
|
281
|
+
return 'accepted';
|
|
282
|
+
return !info.hasRules && info.readsInput ? 'unvalidated' : null;
|
|
283
|
+
}
|
|
284
|
+
/** Aturan validasi action: FormRequest di parameter method, atau validate() / Validator::make di badan method. */
|
|
285
|
+
export async function actionRules(root, action) {
|
|
286
|
+
const controller = await controllerLocation(root, action).catch(() => null);
|
|
287
|
+
const none = { rules: new Map(), readsInput: false, location: controller, controller };
|
|
288
|
+
if (!controller)
|
|
289
|
+
return none;
|
|
290
|
+
const source = await readFile(join(root, controller.file), 'utf8').catch(() => '');
|
|
291
|
+
const method = action.split('@')[1] ?? '__invoke';
|
|
292
|
+
const start = source.search(new RegExp(`function\\s+${method.replace(/\W/g, '')}\\s*\\(`));
|
|
293
|
+
if (start < 0)
|
|
294
|
+
return none;
|
|
295
|
+
const next = source.slice(start + 8).search(/\bfunction\s+\w+\s*\(/);
|
|
296
|
+
const body = source.slice(start, next < 0 ? undefined : start + 8 + next);
|
|
297
|
+
const signature = body.slice(0, body.indexOf(')') + 1);
|
|
298
|
+
const readsInput = /\$request->(input|all|only|get|post|json|validated|safe|except|string|integer|boolean|date)\s*\(|\$request->\w+\b(?!\s*\()|\brequest\(\s*['"]/.test(body);
|
|
299
|
+
const formRequest = /([\\\w]*?(\w+Request))\s+\$\w+/.exec(signature);
|
|
300
|
+
if (formRequest && formRequest[2] !== 'Request' && formRequest[2] !== 'FormRequest') {
|
|
301
|
+
const file = await formRequestFile(root, source, formRequest[1], formRequest[2]);
|
|
302
|
+
const request = file ? await readFile(join(root, file), 'utf8').catch(() => '') : '';
|
|
303
|
+
const at = request.search(/function\s+rules\s*\(/);
|
|
304
|
+
if (at >= 0) {
|
|
305
|
+
const end = request.slice(at + 8).search(/\bfunction\s+\w+\s*\(/);
|
|
306
|
+
return { rules: parseRules(request.slice(at, end < 0 ? undefined : at + 8 + end)), readsInput: true, location: { file: file, line: lineAt(request, at) }, controller };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (/->validate\s*\(|Validator::make\s*\(/.test(body))
|
|
310
|
+
return { rules: parseRules(body), readsInput, location: controller, controller };
|
|
311
|
+
return { ...none, readsInput };
|
|
312
|
+
}
|
|
313
|
+
/** Kelas FormRequest → berkas: nama penuh, `use` di controller, atau cari <Nama>.php di app/Http/Requests. */
|
|
314
|
+
async function formRequestFile(root, controllerSource, written, short) {
|
|
315
|
+
const used = new RegExp(`^use\\s+(App\\\\[\\w\\\\]*\\\\${short})\\s*;`, 'm').exec(controllerSource)?.[1];
|
|
316
|
+
const full = written.replace(/^\\/, '').startsWith('App\\') ? written.replace(/^\\/, '') : used;
|
|
317
|
+
if (full)
|
|
318
|
+
return `app/${full.slice(4).replace(/\\/g, '/')}.php`;
|
|
319
|
+
return findFile(join(root, 'app', 'Http', 'Requests'), `${short}.php`, 4).then((f) => (f ? relative(root, f).replace(/\\/g, '/') : null));
|
|
320
|
+
}
|
|
321
|
+
async function findFile(dir, name, depth) {
|
|
322
|
+
if (depth < 0)
|
|
323
|
+
return null;
|
|
324
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
325
|
+
const full = join(dir, entry.name);
|
|
326
|
+
if (entry.isFile() && entry.name === name)
|
|
327
|
+
return full;
|
|
328
|
+
if (entry.isDirectory()) {
|
|
329
|
+
const found = await findFile(full, name, depth - 1);
|
|
330
|
+
if (found)
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/** POST/PUT/PATCH: body kosong dan body yang melanggar aturan; satu temuan per route (yang terparah). */
|
|
337
|
+
export async function checkValidation(probe) {
|
|
338
|
+
const targets = probe.routes
|
|
339
|
+
.filter((r) => r.action && r.methods.some((m) => WRITE_METHODS.includes(m)) && !IGNORED.test(r.uri) && !AUTH_FLOW.test(r.uri))
|
|
340
|
+
.filter((r) => probe.session || !isProtected(r))
|
|
341
|
+
.slice(0, MAX_WRITE_ROUTES);
|
|
342
|
+
const findings = [];
|
|
343
|
+
let tested = 0;
|
|
344
|
+
for (const route of targets) {
|
|
345
|
+
if (findings.length >= MAX_PER_CHECK)
|
|
346
|
+
break;
|
|
347
|
+
const method = WRITE_METHODS.find((m) => route.methods.includes(m));
|
|
348
|
+
const path = await fillParams(probe, route.uri, probe.session);
|
|
349
|
+
if (path === null)
|
|
350
|
+
continue;
|
|
351
|
+
const info = await actionRules(probe.root, route.action);
|
|
352
|
+
const flags = {
|
|
353
|
+
hasRules: info.rules.size > 0,
|
|
354
|
+
hasRequired: [...info.rules.values()].some((list) => list.includes('required')),
|
|
355
|
+
readsInput: info.readsInput,
|
|
356
|
+
};
|
|
357
|
+
const invalid = invalidPayload(info.rules);
|
|
358
|
+
const cases = [['empty', {}]];
|
|
359
|
+
if (Object.keys(invalid).length)
|
|
360
|
+
cases.push(['invalid', invalid]);
|
|
361
|
+
tested++;
|
|
362
|
+
let worst = null;
|
|
363
|
+
for (const [kind, body] of cases) {
|
|
364
|
+
const res = await send(probe, method, path, probe.session, body);
|
|
365
|
+
const verdict = res ? classifyValidation(res.status, kind, flags) : null;
|
|
366
|
+
if (verdict && (!worst || verdict === 'server_error'))
|
|
367
|
+
worst = { verdict, status: res.status, body, text: res.text };
|
|
368
|
+
if (worst?.verdict === 'server_error')
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
if (!worst)
|
|
372
|
+
continue;
|
|
373
|
+
const request = `${method} ${probe.base}/${path}\n${JSON.stringify(worst.body)}`;
|
|
374
|
+
const location = info.location ?? {};
|
|
375
|
+
const related = info.controller && info.location?.file !== info.controller.file ? [{ repo: 'be', ...info.controller, role: 'controller' }] : [];
|
|
376
|
+
const common = {
|
|
377
|
+
repo: 'be',
|
|
378
|
+
...location,
|
|
379
|
+
...(related.length ? { related_files: related } : {}),
|
|
380
|
+
evidence: { request, response: tail(worst.text, 2000) },
|
|
381
|
+
};
|
|
382
|
+
findings.push(worst.verdict === 'server_error'
|
|
383
|
+
? {
|
|
384
|
+
...common,
|
|
385
|
+
area: 'error_handling',
|
|
386
|
+
severity: 'high',
|
|
387
|
+
title: `${method} /${route.uri}: input tidak valid membuat server error (${worst.status})`,
|
|
388
|
+
description: `${method} /${route.uri}${route.action ? ` (${route.action})` : ''} membalas ${worst.status} untuk ${Object.keys(worst.body).length ? 'nilai yang melanggar aturan validasi' : 'body kosong'}; seharusnya 422 dengan pesan validasi.`,
|
|
389
|
+
suggestion: 'Lengkapi aturan validasi (FormRequest / $request->validate) untuk semua field yang dipakai sebelum menyentuh database.',
|
|
390
|
+
}
|
|
391
|
+
: {
|
|
392
|
+
...common,
|
|
393
|
+
area: 'validation',
|
|
394
|
+
severity: 'medium',
|
|
395
|
+
title: worst.verdict === 'unvalidated'
|
|
396
|
+
? `${method} /${route.uri}: data kosong diterima tanpa validasi`
|
|
397
|
+
: `${method} /${route.uri}: input yang melanggar aturan validasi diterima`,
|
|
398
|
+
description: worst.verdict === 'unvalidated'
|
|
399
|
+
? `${method} /${route.uri} membaca input request tetapi tidak punya aturan validasi; body kosong diterima (${worst.status}).`
|
|
400
|
+
: `${method} /${route.uri} membalas ${worst.status} untuk body ${JSON.stringify(worst.body).slice(0, 300)}; aturan validasinya tidak dijalankan.`,
|
|
401
|
+
suggestion: worst.verdict === 'unvalidated'
|
|
402
|
+
? 'Tambahkan FormRequest atau $request->validate() untuk field yang dibaca action ini.'
|
|
403
|
+
: 'Pastikan FormRequest dipakai di parameter method (bukan Request biasa) atau validate() dipanggil sebelum data disimpan.',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return { findings, notes: [`Uji validasi: ${tested} route POST/PUT/PATCH.`] };
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------- otorisasi
|
|
409
|
+
const resource = (uri) => uri.split('/').filter((s) => !s.startsWith('{')).slice(0, 2).join('/');
|
|
410
|
+
const hasData = (json) => {
|
|
411
|
+
const obj = json;
|
|
412
|
+
return [obj, obj?.data, obj?.data?.data].some((v) => Array.isArray(v) && v.length > 0) || (!!obj?.data && typeof obj.data === 'object' && !Array.isArray(obj.data));
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* - Route API POST/PUT/PATCH/DELETE tanpa middleware auth (statis dari route list).
|
|
416
|
+
* - Route GET publik di resource yang route lainnya terlindungi, dan mengembalikan data tanpa login.
|
|
417
|
+
* - Route ber-middleware role dipanggil dengan akun berlabel role lain: harus 401/403.
|
|
418
|
+
*/
|
|
419
|
+
export async function checkAuthorization(probe, beRoot) {
|
|
420
|
+
const findings = [];
|
|
421
|
+
const routesFile = await readFile(join(beRoot, 'routes', 'api.php'), 'utf8').then(() => 'routes/api.php', () => undefined);
|
|
422
|
+
const openWrites = probe.routes.filter((r) => r.uri.startsWith('api/') && r.methods.some((m) => [...WRITE_METHODS, 'DELETE'].includes(m)) && !isProtected(r) && !AUTH_FLOW.test(r.uri));
|
|
423
|
+
if (openWrites.length) {
|
|
424
|
+
const list = openWrites.slice(0, 15).map((r) => `- ${r.methods.join('|')} /${r.uri}${r.action ? ` (${r.action})` : ''}`);
|
|
425
|
+
const related = [];
|
|
426
|
+
for (const r of openWrites) {
|
|
427
|
+
const location = r.action ? await controllerLocation(beRoot, r.action).catch(() => null) : null;
|
|
428
|
+
if (location && !related.some((f) => f.file === location.file) && related.length < 5)
|
|
429
|
+
related.push({ repo: 'be', ...location, role: 'controller' });
|
|
430
|
+
}
|
|
431
|
+
findings.push({
|
|
432
|
+
repo: 'be',
|
|
433
|
+
area: 'security',
|
|
434
|
+
severity: 'high',
|
|
435
|
+
title: `${openWrites.length} route API pengubah data tanpa autentikasi`,
|
|
436
|
+
description: `Route berikut bisa mengubah data tanpa login (tidak ada middleware auth):\n${list.join('\n')}${openWrites.length > 15 ? `\n- … ${openWrites.length - 15} lainnya` : ''}`,
|
|
437
|
+
file: routesFile,
|
|
438
|
+
...(related.length ? { related_files: related } : {}),
|
|
439
|
+
suggestion: "Pindahkan route ini ke grup Route::middleware('auth:sanctum'), kecuali memang sengaja publik.",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const guarded = new Set(probe.routes.filter(isProtected).map((r) => resource(r.uri)));
|
|
443
|
+
const leaks = [];
|
|
444
|
+
for (const route of probe.routes.filter((r) => r.uri.startsWith('api/') && r.methods.includes('GET') && !r.uri.includes('{') && !isProtected(r))) {
|
|
445
|
+
if (!guarded.has(resource(route.uri)) || AUTH_FLOW.test(route.uri))
|
|
446
|
+
continue;
|
|
447
|
+
const res = await send(probe, 'GET', route.uri, null);
|
|
448
|
+
if (res && res.status >= 200 && res.status < 300 && hasData(res.json))
|
|
449
|
+
leaks.push(`- GET /${route.uri}${route.action ? ` (${route.action})` : ''}`);
|
|
450
|
+
}
|
|
451
|
+
if (leaks.length) {
|
|
452
|
+
findings.push({
|
|
453
|
+
repo: 'be',
|
|
454
|
+
area: 'security',
|
|
455
|
+
severity: 'medium',
|
|
456
|
+
title: `${leaks.length} route API mengembalikan data tanpa login`,
|
|
457
|
+
description: `Route lain di resource yang sama memakai auth, tetapi route berikut membalas data tanpa login:\n${leaks.slice(0, 15).join('\n')}`,
|
|
458
|
+
file: routesFile,
|
|
459
|
+
suggestion: 'Periksa apakah data ini memang publik; bila tidak, tambahkan middleware auth.',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
let roleTests = 0;
|
|
463
|
+
const labelled = probe.sessions.filter((s) => s.label);
|
|
464
|
+
if (labelled.length) {
|
|
465
|
+
const roleRoutes = probe.routes.filter((r) => r.methods.includes('GET') && allowedRoles(r)).slice(0, MAX_ROLE_ROUTES);
|
|
466
|
+
for (const route of roleRoutes) {
|
|
467
|
+
if (findings.length >= MAX_PER_CHECK)
|
|
468
|
+
break;
|
|
469
|
+
const allowed = allowedRoles(route);
|
|
470
|
+
const owner = labelled.find((s) => allowed.includes(s.label)) ?? probe.session;
|
|
471
|
+
const path = await fillParams(probe, route.uri, owner);
|
|
472
|
+
if (path === null)
|
|
473
|
+
continue;
|
|
474
|
+
for (const session of labelled.filter((s) => !allowed.includes(s.label))) {
|
|
475
|
+
roleTests++;
|
|
476
|
+
const res = await send(probe, 'GET', path, session);
|
|
477
|
+
if (!res || res.status < 200 || res.status >= 300)
|
|
478
|
+
continue;
|
|
479
|
+
findings.push({
|
|
480
|
+
repo: 'be',
|
|
481
|
+
area: 'security',
|
|
482
|
+
severity: 'high',
|
|
483
|
+
title: `Akun ${session.label} bisa membuka GET /${route.uri} (khusus ${allowed.join(', ')})`,
|
|
484
|
+
description: `Route GET /${route.uri} ber-middleware role ${allowed.join(', ')}, tetapi akun berlabel "${session.label}" mendapat ${res.status}.`,
|
|
485
|
+
...(await locate(probe, route)),
|
|
486
|
+
evidence: { request: `GET ${probe.base}/${path} (akun ${session.label})`, response: tail(res.text, 2000) },
|
|
487
|
+
suggestion: 'Periksa middleware role dan role akun seed; pastikan pengecekan role tidak selalu lolos.',
|
|
488
|
+
});
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
findings: findings.slice(0, MAX_PER_CHECK),
|
|
495
|
+
notes: [
|
|
496
|
+
`Uji otorisasi: ${openWrites.length} route pengubah data tanpa auth, ${leaks.length} route membalas data tanpa login${roleTests ? `, ${roleTests} uji antar-role` : labelled.length ? '' : ' (uji antar-role butuh akun berlabel, mis. "admin: a@x / pw")'}.`,
|
|
497
|
+
],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* URL pemanggilan di kode FE → path: base di depan (`${API_URL}/produk`) dibuang, `/${id}` → `/__p__`, query dibuang.
|
|
502
|
+
* null = URL absolut ke host lain, berkas statis, atau bukan path.
|
|
503
|
+
*/
|
|
504
|
+
export function normalizeCallPath(raw) {
|
|
505
|
+
let s = raw.trim().replace(/^\$\{[^}]*\}/, '');
|
|
506
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) || s.startsWith('//'))
|
|
507
|
+
return null;
|
|
508
|
+
s = s.split(/[?#]/)[0];
|
|
509
|
+
s = s.replace(/\/\$\{[^}]*\}/g, '/__p__').replace(/\$\{[^}]*\}/g, '');
|
|
510
|
+
if (!s.startsWith('/')) {
|
|
511
|
+
if (!/^[\w-]+\//.test(s))
|
|
512
|
+
return null;
|
|
513
|
+
s = `/${s}`;
|
|
514
|
+
}
|
|
515
|
+
if (/\.[a-z0-9]{2,5}$/i.test(s))
|
|
516
|
+
return null;
|
|
517
|
+
s = s.replace(/\/+$/, '');
|
|
518
|
+
return s && /^[\w/.~:-]+$/.test(s) ? s : null;
|
|
519
|
+
}
|
|
520
|
+
/** axios-like `.get('/x')` / `.post(\`/x/${id}\`)` dan fetch('/x', { method }) di satu berkas sumber. */
|
|
521
|
+
export function extractApiCalls(source, file) {
|
|
522
|
+
const calls = [];
|
|
523
|
+
const push = (method, raw, index, end) => {
|
|
524
|
+
const path = normalizeCallPath(raw);
|
|
525
|
+
if (!path)
|
|
526
|
+
return;
|
|
527
|
+
// _method di baris yang sama atau FormData.append('_method', …) di ±200 karakter sebelumnya
|
|
528
|
+
const from = source.lastIndexOf('\n', Math.max(0, index - 200)) + 1;
|
|
529
|
+
const to = source.indexOf('\n', end);
|
|
530
|
+
const spoofed = method.toUpperCase() === 'POST' && /_method/.test(source.slice(from, to < 0 ? undefined : to));
|
|
531
|
+
calls.push({ method: method.toUpperCase(), path, file, line: lineAt(source, index), spoofed });
|
|
532
|
+
};
|
|
533
|
+
for (const m of source.matchAll(/\.(get|post|put|patch|delete)\s*(?:<[^<>()]*>)?\s*\(\s*(['"`])((?:\\.|(?!\2)[^\\\n])*?)\2/g)) {
|
|
534
|
+
push(m[1], m[3], m.index, m.index + m[0].length);
|
|
535
|
+
}
|
|
536
|
+
for (const m of source.matchAll(/(?<![\w.])\$?fetch\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])*?)\1\s*(?:,\s*\{([^}]*)\})?/g)) {
|
|
537
|
+
const method = /method\s*:\s*['"`](\w+)['"`]/i.exec(m[3] ?? '')?.[1] ?? 'GET';
|
|
538
|
+
push(method, m[2], m.index, m.index + m[0].length);
|
|
539
|
+
}
|
|
540
|
+
return calls;
|
|
541
|
+
}
|
|
542
|
+
/** Path FE cocok dengan URI route: {param} BE cocok segmen apa pun, __p__ FE cocok segmen apa pun. */
|
|
543
|
+
export function routeMatches(uri, path) {
|
|
544
|
+
const a = uri.replace(/\/?\{[\w:]+\?\}/g, '').split('/').filter(Boolean);
|
|
545
|
+
const b = path.split('/').filter(Boolean);
|
|
546
|
+
return a.length === b.length && a.every((seg, i) => seg.startsWith('{') || b[i] === '__p__' || seg === b[i]);
|
|
547
|
+
}
|
|
548
|
+
/** Path dasar API dari .env.example FE (VITE_*=http…/api) dan axios.create({ baseURL: '/api' }). */
|
|
549
|
+
export function basePaths(envExample, sources) {
|
|
550
|
+
const bases = new Set(['']);
|
|
551
|
+
for (const m of envExample.matchAll(/^VITE_[A-Z0-9_]+=["']?(https?:\/\/[^"'\s]*)["']?\s*$/gm)) {
|
|
552
|
+
bases.add(new URL(m[1]).pathname.replace(/\/$/, ''));
|
|
553
|
+
}
|
|
554
|
+
for (const source of sources) {
|
|
555
|
+
for (const m of source.matchAll(/baseURL\s*:\s*(['"`])([^'"`$]+)\1/g)) {
|
|
556
|
+
const value = m[2];
|
|
557
|
+
const path = /^https?:\/\//.test(value) ? new URL(value).pathname : value;
|
|
558
|
+
if (path.startsWith('/'))
|
|
559
|
+
bases.add(path.replace(/\/$/, ''));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return [...bases];
|
|
563
|
+
}
|
|
564
|
+
/** Pemanggilan API di src/ FE yang path-nya tidak ada di BE, atau method-nya tidak diterima route BE. */
|
|
565
|
+
export async function checkContract(feRoot, beRoot, routes) {
|
|
566
|
+
if (!routes.length)
|
|
567
|
+
return { findings: [], notes: [] };
|
|
568
|
+
const files = (await listSources(join(feRoot, 'src'), 6)).filter((f) => !/\.(spec|test)\.[jt]sx?$/.test(f) && !/[\\/](__tests__|__mocks__|mocks?|msw)[\\/]/.test(f));
|
|
569
|
+
const sources = await Promise.all(files.map(async (f) => ({ file: relative(feRoot, f).replace(/\\/g, '/'), source: await readFile(f, 'utf8').catch(() => '') })));
|
|
570
|
+
const env = (await readFile(join(feRoot, '.env.example'), 'utf8').catch(() => '')) || (await readFile(join(feRoot, '.env'), 'utf8').catch(() => ''));
|
|
571
|
+
const bases = basePaths(env, sources.map((s) => s.source));
|
|
572
|
+
const firstSegments = new Set(routes.map((r) => r.uri.split('/')[0]));
|
|
573
|
+
const problems = new Map();
|
|
574
|
+
let checked = 0;
|
|
575
|
+
for (const { file, source } of sources) {
|
|
576
|
+
for (const call of extractApiCalls(source, file)) {
|
|
577
|
+
checked++;
|
|
578
|
+
const candidates = bases.map((base) => `${base}${call.path}`);
|
|
579
|
+
const byPath = candidates.map((p) => ({ p, hits: routes.filter((r) => routeMatches(r.uri, p)) })).find((c) => c.hits.length);
|
|
580
|
+
if (byPath) {
|
|
581
|
+
if (call.spoofed || byPath.hits.some((r) => r.methods.includes(call.method)))
|
|
582
|
+
continue;
|
|
583
|
+
const key = `method ${call.method} ${byPath.p}`;
|
|
584
|
+
const entry = problems.get(key) ?? { kind: 'method', method: call.method, path: byPath.p, calls: [], others: byPath.hits };
|
|
585
|
+
entry.calls.push(call);
|
|
586
|
+
problems.set(key, entry);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
// hanya path yang jelas mengarah ke BE ini (segmen pertama sama dengan route BE)
|
|
590
|
+
const target = candidates.find((p) => firstSegments.has(p.split('/').filter(Boolean)[0]));
|
|
591
|
+
if (!target)
|
|
592
|
+
continue;
|
|
593
|
+
const key = `missing ${call.method} ${target}`;
|
|
594
|
+
const entry = problems.get(key) ?? { kind: 'missing', method: call.method, path: target, calls: [], others: [] };
|
|
595
|
+
entry.calls.push(call);
|
|
596
|
+
problems.set(key, entry);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const findings = [];
|
|
600
|
+
for (const problem of [...problems.values()].sort((a, b) => Number(a.kind === 'missing') - Number(b.kind === 'missing')).slice(0, MAX_PER_CHECK)) {
|
|
601
|
+
const first = problem.calls[0];
|
|
602
|
+
const shown = problem.path.replace(/__p__/g, '{…}');
|
|
603
|
+
const where = problem.calls.slice(0, 5).map((c) => `- ${c.file}:${c.line}`).join('\n');
|
|
604
|
+
const related = [];
|
|
605
|
+
for (const r of problem.others) {
|
|
606
|
+
const location = r.action ? await controllerLocation(beRoot, r.action).catch(() => null) : null;
|
|
607
|
+
if (location && !related.some((f) => f.file === location.file))
|
|
608
|
+
related.push({ repo: 'be', ...location, role: 'controller' });
|
|
609
|
+
}
|
|
610
|
+
const accepted = [...new Set(problem.others.flatMap((r) => r.methods))].join(', ');
|
|
611
|
+
findings.push({
|
|
612
|
+
repo: 'fe',
|
|
613
|
+
area: 'api_contract',
|
|
614
|
+
severity: problem.kind === 'method' ? 'high' : 'medium',
|
|
615
|
+
title: problem.kind === 'method'
|
|
616
|
+
? `FE memanggil ${problem.method} ${shown}, BE hanya menerima ${accepted}`
|
|
617
|
+
: `FE memanggil ${problem.method} ${shown} yang tidak ada di route BE`,
|
|
618
|
+
description: `${problem.kind === 'method' ? `Route BE /${problem.others[0].uri} ada, tetapi tidak menerima ${problem.method}.` : 'Tidak ada route BE (php artisan route:list) yang cocok dengan path ini.'}\nDipanggil di:\n${where}`,
|
|
619
|
+
file: first.file,
|
|
620
|
+
line: first.line,
|
|
621
|
+
...(related.length ? { related_files: related.slice(0, 5) } : {}),
|
|
622
|
+
suggestion: problem.kind === 'method'
|
|
623
|
+
? 'Samakan method HTTP di pemanggil FE dengan route BE (atau tambahkan route BE untuk method ini).'
|
|
624
|
+
: 'Perbaiki path di FE, atau tambahkan route yang hilang di BE.',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return { findings, notes: [`Cek kontrak FE↔BE: ${checked} pemanggilan API di kode FE dicocokkan dengan ${routes.length} route BE.`] };
|
|
628
|
+
}
|