Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ model Target {
model Scan {
id String @id @default(cuid())
status ScanStatus @default(PENDING)
progress Int @default(0) // 🆕 نسبة التقدم المئوية
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
Expand Down
20 changes: 20 additions & 0 deletions backend/src/controllers/scans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,23 @@ export const listScans = async (
next(error);
}
};

export const cancelScan = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const userId = req.kullanici!.id;
const { id } = req.params;

if (!id) {
return res.status(400).json({ message: 'Scan ID is required' });
}

await scanService.cancelScan(userId, id);
res.status(200).json({ message: 'Scan cancellation requested' });
} catch (error) {
next(error);
}
};
18 changes: 18 additions & 0 deletions backend/src/routes/scans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,22 @@ router.get('/', kimlikDoğrula, listScansValidation, scanController.listScans);
*/
router.get('/:id', kimlikDoğrula, getScanValidation, scanController.getScan);

/**
* @swagger
* /scans/{id}/cancel:
* post:
* summary: Cancels an active scan
* tags: [Scans]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: string }
* responses:
* 200: { description: Scan cancelled }
*/
router.post('/:id/cancel', kimlikDoğrula, scanController.cancelScan);

export default router;
30 changes: 30 additions & 0 deletions backend/src/services/scans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,34 @@ export const listScansForOrg = async (userId: string, organizationId: string) =>
});

return scans;
};

/**
* يلغي فحصاً جارياً أو قيد الانتظار.
* @param userId - معرف المستخدم.
* @param scanId - معرف الفحص.
*/
export const cancelScan = async (userId: string, scanId: string) => {
// 1. التأكد من وجود الفحص وصلاحيات المستخدم
const scan = await getScanById(userId, scanId);

// 2. تحديث الحالة فقط إذا كانت RUNNING أو QUEUED أو PENDING
const activeStatuses = ['RUNNING', 'QUEUED', 'PENDING'];
if (activeStatuses.includes(scan.status)) {
await prisma.scan.updateMany({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The BullMQ job is never removed or signaled to stop after marking the scan as CANCELED. The worker will continue processing and could overwrite the status back to COMPLETED when it finishes, making the cancel appear successful while work continues. Consider storing the BullMQ job ID on the scan record and calling job.remove() or using the AbortSignal-based cancellation pattern here, or at minimum ensure the worker checks the DB status before writing terminal states.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/services/scans.ts, line 191:

<comment>The BullMQ job is never removed or signaled to stop after marking the scan as `CANCELED`. The worker will continue processing and could overwrite the status back to `COMPLETED` when it finishes, making the cancel appear successful while work continues. Consider storing the BullMQ job ID on the scan record and calling `job.remove()` or using the `AbortSignal`-based cancellation pattern here, or at minimum ensure the worker checks the DB status before writing terminal states.</comment>

<file context>
@@ -188,14 +188,16 @@ export const cancelScan = async (userId: string, scanId: string) => {
-        await prisma.scan.update({
-            where: { id: scanId },
-            data: { 
+        await prisma.scan.updateMany({
+            where: {
+                id: scanId,
</file context>

Tip: Review your code locally with the cubic CLI to iterate faster.

where: {
id: scanId,
status: { in: activeStatuses as ScanStatus[] },
},
data: {
status: ScanStatus.CANCELED,
completedAt: new Date() // نعتبره مكتملاً (متوقفاً) لتسجيل الوقت
}
});
// ملاحظة: إيقاف الـ Job الفعلي من BullMQ يتطلب معرف JobId.
// في هذه المرحلة، نكتفي بتحديث حالة قاعدة البيانات.
// الـ Worker يجب أن يتحقق من حالة الفحص في قاعدة البيانات قبل الاستمرار.
}

return true;
};
Comment on lines +184 to 207
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Cancellation needs to be atomic and job-aware.

The current read-then-update flow leaves a TOCTOU window, and the BullMQ job is never removed or stopped. A scan can still finish after cancellation and be written back as COMPLETED, which makes the cancel action look successful while the work keeps running.

Please make the state transition conditional in one step and coordinate queue removal or an early worker exit before returning success.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/services/scans.ts` around lines 184 - 205, Make the cancellation
atomic and job-aware by replacing the read-then-update pattern in cancelScan
with a conditional single-step update (e.g., use prisma.scan.update or
updateMany with a where that includes id: scanId and status in
['RUNNING','QUEUED','PENDING'] and set status to ScanStatus.CANCELED and
completedAt); after the conditional update, check whether any row was changed
and, if so, use the scan.jobId (or the field that stores BullMQ job id on the
Scan) to locate the BullMQ job via the Queue API (e.g., queue.getJob(jobId)) and
remove/discard/fail it so the worker stops, returning false if no row was
updated; keep worker logic to re-check scan status before completing work.

20 changes: 20 additions & 0 deletions backend/src/worker/sqli/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export async function runSqliScan(job: Job, prisma: PrismaClient): Promise<void>
if (await executeAuthBypassAttack(job, prisma)) totalVulnerabilities++;

await job.updateProgress(10); // تحديث التقدم المبدئي
// 🆕 حفظ التقدم الأولي
try {
// @ts-ignore: Prisma Client types might be stale
await prisma.scan.update({ where: { id: scanId }, data: { progress: 10 } });
} catch (e) { /* Ignore stale client error */ }

// --- المرحلة الثانية: الفحص الدقيق لكل بارامتر (Waves 2-6) ---
// نحتفظ بنسخة من البيانات الأصلية لتجنب تلوث البيانات أثناء التكرار
Expand Down Expand Up @@ -73,6 +78,18 @@ export async function runSqliScan(job: Job, prisma: PrismaClient): Promise<void>
// المعادلة: نوزع 80% من التقدم على هذه المرحلة
const progress = 10 + Math.floor(((i + 1) / totalParams) * 80);
await job.updateProgress(progress);

// 🆕 تحديث التقدم في قاعدة البيانات للعرض في الواجهة (مع حماية من الأخطاء)
try {
// @ts-ignore: Prisma Client types might be stale
await prisma.scan.update({
where: { id: scanId },
data: { progress: progress }
});
} catch (e) {
// قد يفشل إذا لم يتم تحديث Prisma Client بعد، نتجاهل الخطأ لكي لا يتوقف الفحص
console.warn('[Orchestrator] Failed to sync progress to DB (Non-fatal)');
}
}

// استعادة البيانات الأصلية للمرحلة الأخيرة
Expand All @@ -84,12 +101,15 @@ export async function runSqliScan(job: Job, prisma: PrismaClient): Promise<void>
if (await executeSecondOrderAttack(job, prisma)) totalVulnerabilities++;

await job.updateProgress(100);
// لا نحتاج لتحديث التقدم هنا لأن التحديث الأخير سيضع الحالة COMPLETED والتقدم 100

// --- إتمام المهمة ---
// @ts-ignore: Prisma Client types might be stale
await prisma.scan.update({
where: { id: scanId },
data: {
status: 'COMPLETED',
progress: 100, // 🆕 تأكيد الوصول لـ 100%
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The final completion write unconditionally sets progress: 100 without the stale-client fallback used above, so schema/client drift can cause successful scans to be marked as FAILED at the very end.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/worker/sqli/orchestrator.ts, line 112:

<comment>The final completion write unconditionally sets `progress: 100` without the stale-client fallback used above, so schema/client drift can cause successful scans to be marked as FAILED at the very end.</comment>

<file context>
@@ -84,12 +101,15 @@ export async function runSqliScan(job: Job, prisma: PrismaClient): Promise<void>
             where: { id: scanId },
             data: { 
                 status: 'COMPLETED', 
+                progress: 100, // 🆕 تأكيد الوصول لـ 100%
                 completedAt: new Date(),
                 // يمكن إضافة حقل لعدد النتائج إذا كان مدعوماً في قاعدة البيانات
</file context>

completedAt: new Date(),
// يمكن إضافة حقل لعدد النتائج إذا كان مدعوماً في قاعدة البيانات
// findingsCount: totalVulnerabilities
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/entities/scan/api/scanApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ export const scanApi = {
getOne: async (id: string): Promise<Scan> => {
const { data } = await apiClient.get<{ data: Scan }>(`/scans/${id}`);
return data.data;
},

// 🆕 إيقاف الفحص
cancel: async (id: string): Promise<void> => {
await apiClient.post(`/scans/${id}/cancel`);
}
};
3 changes: 2 additions & 1 deletion frontend/src/entities/scan/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export type ScanStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'STOPPED';
export type ScanStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELED';

export interface Scan {
id: string;
status: ScanStatus;
progress?: number; // 🆕 نسبة التقدم (0-100)
startedAt: string | null;
completedAt: string | null;
createdAt: string;
Comment on lines 3 to 9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make progress required.

The backend now guarantees a numeric progress value for every scan, so keeping this optional only forces consumers into undefined/falsy handling and makes 0% indistinguishable from missing data.

♻️ Suggested tweak
 export interface Scan {
   id: string;
   status: ScanStatus;
-  progress?: number; // 🆕 نسبة التقدم (0-100)
+  progress: number; // 0-100
   startedAt: string | null;
   completedAt: string | null;
   createdAt: string;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface Scan {
id: string;
status: ScanStatus;
progress?: number; // 🆕 نسبة التقدم (0-100)
startedAt: string | null;
completedAt: string | null;
createdAt: string;
export interface Scan {
id: string;
status: ScanStatus;
progress: number; // 0-100
startedAt: string | null;
completedAt: string | null;
createdAt: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/entities/scan/model/types.ts` around lines 3 - 9, The Scan
interface currently declares progress as optional; change the Scan type
definition so the progress property is required (remove the optional modifier
from progress) so progress: number; in the Scan interface
(frontend/src/entities/scan/model/types.ts, symbol: Scan) and update any usages
that defensively check for undefined to rely on a numeric value instead (e.g.,
callers that treat 0 vs missing).

Expand Down
50 changes: 40 additions & 10 deletions frontend/src/pages/scans/ui/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,23 @@ import {
} from 'lucide-react';
import { scanApi } from '@/entities/scan/api/scanApi';
import { useAuthStore } from '@/features/auth/model/authStore';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { ScanStatus } from '@/entities/scan/model/types';

const ScansPage: React.FC = () => {
const queryClient = useQueryClient(); // Add this
const { user } = useAuthStore();
const { t } = useTranslation();

// Cancel Scan Mutation
const cancelMutation = useMutation({
mutationFn: (scanId: string) => scanApi.cancel(scanId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scans'] });
},
});

// Fetch Scans with polling
const { data: scans = [], isLoading, refetch, isRefetching } = useQuery({
queryKey: ['scans', user?.organizationId],
Expand All @@ -36,6 +45,7 @@ const ScansPage: React.FC = () => {
case 'PENDING': return 'text-blue-400';
case 'COMPLETED': return 'text-purple-400';
case 'FAILED': return 'text-cyber-red';
case 'CANCELED': return 'text-white/40';
default: return 'text-white/40';
}
};
Expand All @@ -46,6 +56,7 @@ const ScansPage: React.FC = () => {
case 'PENDING': return <Clock className="w-4 h-4" />;
case 'COMPLETED': return <CheckCircle2 className="w-4 h-4" />;
case 'FAILED': return <AlertCircle className="w-4 h-4" />;
case 'CANCELED': return <StopCircle className="w-4 h-4" />;
default: return <StopCircle className="w-4 h-4" />;
}
};
Expand Down Expand Up @@ -124,13 +135,27 @@ const ScansPage: React.FC = () => {
</div>

<div className="col-span-2">
<div className="w-full h-1 bg-cyber-green/5 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: scan.status === 'COMPLETED' ? '100%' : scan.status === 'RUNNING' ? '65%' : '0%' }}
className={`h-full ${getStatusColor(scan.status).replace('text-', 'bg-')} shadow-[0_0_10px_currentColor]`}
/>
<div className="w-full h-1 bg-cyber-green/5 rounded-full overflow-hidden relative">
{scan.status === 'RUNNING' && scan.progress == null ? (
// Indeterminate Loading State
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className={`h-full w-1/3 ${getStatusColor(scan.status).replace('text-', 'bg-')} shadow-[0_0_10px_currentColor]`}
/>
) : (
// Determined Progress or Static State
<motion.div
initial={{ width: 0 }}
animate={{ width: scan.status === 'COMPLETED' ? '100%' : `${scan.progress || 0}%` }}
className={`h-full ${getStatusColor(scan.status).replace('text-', 'bg-')} shadow-[0_0_10px_currentColor]`}
/>
)}
</div>
<p className="text-[9px] font-mono text-right text-cyber-green/60 mt-1">
{scan.status === 'RUNNING' && scan.progress == null ? 'ANALYZING...' : `${scan.status === 'COMPLETED' ? 100 : (scan.progress ?? 0)}%`}
</p>
</div>

<div className="col-span-2">
Expand All @@ -147,9 +172,14 @@ const ScansPage: React.FC = () => {
<button className="px-3 py-1 bg-purple-500/10 border border-purple-500/40 text-purple-400 text-[10px] font-mono font-bold uppercase hover:bg-purple-500 hover:text-black transition-all flex items-center gap-1">
Report <ChevronRight size={12} />
</button>
) : scan.status === 'RUNNING' ? (
<button className="p-1.5 border border-cyber-red/20 text-cyber-red/40 hover:bg-cyber-red/10 hover:text-cyber-red hover:border-cyber-red transition-all" title="Terminate">
<StopCircle size={16} />
) : scan.status === 'RUNNING' || scan.status === 'PENDING' ? (
<button
onClick={() => cancelMutation.mutate(scan.id)}
disabled={cancelMutation.isPending}
className="p-1.5 border border-cyber-red/20 text-cyber-red/40 hover:bg-cyber-red/10 hover:text-cyber-red hover:border-cyber-red transition-all disabled:opacity-50"
title="Terminate Operation"
>
{cancelMutation.isPending ? <Loader2 size={16} className="animate-spin" /> : <StopCircle size={16} />}
</button>
) : (
<button className="p-1.5 border border-cyber-green/20 text-cyber-green/40 hover:bg-cyber-green/10 hover:text-cyber-green hover:border-cyber-green transition-all" title="Restart">
Expand Down
Loading