diff --git a/backend/src/routes/threat_detections.js b/backend/src/routes/threat_detections.js index edb6fc1..0c496e7 100644 --- a/backend/src/routes/threat_detections.js +++ b/backend/src/routes/threat_detections.js @@ -5,7 +5,6 @@ const Threat_detectionsService = require('../services/threat_detections'); const Threat_detectionsDBApi = require('../db/api/threat_detections'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); const router = express.Router(); @@ -125,6 +124,21 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ + + +router.post('/analyze', wrapAsync(async (req, res) => { + const payload = await Threat_detectionsService.analyze(req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.get('/assistant/recent', wrapAsync(async (req, res) => { + const payload = await Threat_detectionsService.recentAssistantFindings( + req.currentUser, + req.query.limit, + ); + res.status(200).send({ rows: payload, count: payload.length }); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/services/threat_detections.js b/backend/src/services/threat_detections.js index 421f413..9016a65 100644 --- a/backend/src/services/threat_detections.js +++ b/backend/src/services/threat_detections.js @@ -3,12 +3,157 @@ const Threat_detectionsDBApi = require('../db/api/threat_detections'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const PHISHING_PATTERNS = [ + { label: 'Urgent pressure language', weight: 14, pattern: /\b(urgent|immediately|final notice|act now|within 24 hours|account will be closed)\b/i }, + { label: 'Credential or password request', weight: 18, pattern: /\b(password|credentials|verify your account|confirm your identity|login to continue|security update)\b/i }, + { label: 'Payment or gift-card lure', weight: 15, pattern: /\b(wire transfer|gift card|bitcoin|crypto wallet|invoice attached|payment failed)\b/i }, + { label: 'Attachment execution lure', weight: 16, pattern: /\b(enable macros|open the attachment|download invoice|run the file|security patch)\b/i }, + { label: 'Brand impersonation wording', weight: 11, pattern: /\b(microsoft|google|paypal|docusign|dropbox|office 365|banking portal)\b/i }, +]; + +const URL_PATTERNS = [ + { label: 'URL contains an IP address host', weight: 20, pattern: /https?:\/\/\d{1,3}(\.\d{1,3}){3}/i }, + { label: 'Punycode or homograph indicator', weight: 16, pattern: /xn--/i }, + { label: 'Suspicious top-level domain', weight: 12, pattern: /\.(zip|mov|top|click|work|rest|country|gq|tk|ml)(\/|$)/i }, + { label: 'URL hides destination with @ symbol', weight: 18, pattern: /https?:\/\/[^\s]+@/i }, + { label: 'Known URL shortener', weight: 10, pattern: /\b(bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd)\b/i }, +]; + +const MALWARE_PATTERNS = [ + { label: 'Executable or script file extension', weight: 22, pattern: /\.(exe|scr|bat|cmd|js|vbs|ps1|jar|dll|hta|iso)\b/i }, + { label: 'Office macro-enabled file extension', weight: 14, pattern: /\.(docm|xlsm|pptm)\b/i }, + { label: 'Suspicious process or shell behavior', weight: 20, pattern: /\b(powershell|cmd\.exe|rundll32|regsvr32|wscript|cscript|encodedcommand)\b/i }, + { label: 'Persistence or credential-access behavior', weight: 18, pattern: /\b(run key|startup folder|credential dump|mimikatz|lsass|keylogger)\b/i }, + { label: 'Command-and-control network indicator', weight: 17, pattern: /\b(beacon|tor exit|dns tunnel|c2|command and control|port 4444|port 1337)\b/i }, +]; + +const SAFE_PATTERNS = [ + { label: 'Uses HTTPS URL', weight: -4, pattern: /https:\/\//i }, + { label: 'Mentions SPF/DKIM/DMARC pass', weight: -8, pattern: /\b(spf pass|dkim pass|dmarc pass)\b/i }, + { label: 'Known institutional domain signal', weight: -6, pattern: /\b(\.edu|\.gov)\b/i }, +]; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function redactSensitiveText(value) { + if (!value) return ''; + return String(value) + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]') + .replace(/https?:\/\/\S+/gi, '[url]') + .replace(/\b\d{12,19}\b/g, '[number]') + .slice(0, 600); +} + +function addMatches(source, patterns, indicators) { + patterns.forEach((item) => { + if (item.pattern.test(source)) { + indicators.push({ label: item.label, weight: item.weight }); + } + }); +} + +function assessThreat(data) { + const source = [ + data.title, + data.content_text, + data.url, + data.fileName, + data.sha256, + data.network_activity, + ].filter(Boolean).join('\n'); + + const indicators = []; + addMatches(source, PHISHING_PATTERNS, indicators); + addMatches(source, URL_PATTERNS, indicators); + addMatches(source, MALWARE_PATTERNS, indicators); + addMatches(source, SAFE_PATTERNS, indicators); + + let riskScore = 12 + indicators.reduce((sum, item) => sum + item.weight, 0); + + if (data.submission_type === 'url') riskScore += 4; + if (data.submission_type === 'file') riskScore += 6; + if (data.submission_type === 'network_traffic') riskScore += 8; + if (source.length > 2500) riskScore += 3; + + riskScore = clamp(Math.round(riskScore), 0, 100); + + const positiveIndicators = indicators.filter((item) => item.weight > 0); + const topIndicators = positiveIndicators + .sort((a, b) => b.weight - a.weight) + .slice(0, 6); + + const threatType = data.submission_type === 'file' || data.submission_type === 'network_traffic' + ? (riskScore >= 35 ? 'malware' : 'benign') + : (riskScore >= 35 ? (data.submission_type === 'url' ? 'suspicious_url' : 'phishing') : 'benign'); + + const severity = riskScore >= 90 + ? 'critical' + : riskScore >= 72 + ? 'high' + : riskScore >= 45 + ? 'medium' + : riskScore >= 20 + ? 'low' + : 'info'; + + const verdict = riskScore >= 75 + ? 'block' + : riskScore >= 45 + ? 'warn' + : riskScore >= 20 + ? 'needs_review' + : 'allow'; + + const confidence = clamp( + Number((0.56 + topIndicators.length * 0.055 + Math.abs(riskScore - 50) / 250).toFixed(2)), + 0.56, + 0.96, + ); + + const summary = topIndicators.length + ? `${severity.toUpperCase()} risk ${threatType.replace('_', ' ')} signal: ${topIndicators.map((item) => item.label).join(', ')}.` + : 'No strong malicious indicators were found; keep monitoring and verify sender/source context.'; + + return { + threatType, + severity, + verdict, + riskScore, + confidence, + summary, + indicators: topIndicators, + allIndicators: indicators, + }; +} + +function validateAnalysisPayload(data) { + const validTypes = ['email', 'message', 'url', 'file', 'network_traffic']; + + if (!data || typeof data !== 'object') { + throw new ValidationError('analysisPayloadMissing', 'Analysis payload is required.'); + } + + if (!validTypes.includes(data.submission_type)) { + throw new ValidationError('analysisTypeInvalid', 'Choose email, message, URL, file, or network traffic.'); + } + + const hasSignal = [data.content_text, data.url, data.fileName, data.sha256, data.network_activity] + .some((value) => String(value || '').trim().length > 0); + + if (!hasSignal) { + throw new ValidationError('analysisSignalMissing', 'Add text, a URL, a file name/hash, or network behavior to analyze.'); + } + + if (String(data.content_text || '').length > 20000) { + throw new ValidationError('analysisTextTooLong', 'Analysis text must be 20,000 characters or less.'); + } +} module.exports = class Threat_detectionsService { @@ -28,9 +173,9 @@ module.exports = class Threat_detectionsService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -95,7 +240,7 @@ module.exports = class Threat_detectionsService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,6 +277,127 @@ module.exports = class Threat_detectionsService { } } + + static async analyze(data, currentUser) { + validateAnalysisPayload(data); + + const transaction = await db.sequelize.transaction(); + + try { + const now = new Date(); + const assessment = assessThreat(data); + const organizationId = currentUser?.organizationsId + || currentUser?.organizations?.id + || currentUser?.organization?.id + || null; + const rawSignal = [ + data.title, + data.content_text, + data.url, + data.fileName, + data.sha256, + data.network_activity, + ].filter(Boolean).join('\n'); + const signalHash = crypto.createHash('sha256').update(rawSignal).digest('hex'); + const privacyMode = data.privacy_mode !== false; + const storedText = privacyMode ? redactSensitiveText(data.content_text || data.network_activity || '') : String(data.content_text || data.network_activity || '').slice(0, 20000); + + const submission = await db.analysis_submissions.create({ + submission_type: data.submission_type, + title: String(data.title || data.fileName || data.url || 'Untitled analysis').slice(0, 180), + content_text: storedText, + url: data.submission_type === 'url' ? String(data.url || '').slice(0, 2000) : null, + sha256: String(data.sha256 || signalHash).slice(0, 128), + processing_location: 'local', + status: 'completed', + submitted_at: now, + completed_at: now, + submitted_byId: currentUser?.id || null, + organizationsId: organizationId, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, { transaction }); + + const detection = await db.threat_detections.create({ + threat_type: assessment.threatType, + severity: assessment.severity, + risk_score: assessment.riskScore, + verdict: assessment.verdict, + is_false_positive: false, + is_false_negative: false, + summary: assessment.summary, + detected_at: now, + submissionId: submission.id, + organizationsId: organizationId, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, { transaction }); + + const explanationText = assessment.indicators.length + ? `The analyzer flagged this item because it matched ${assessment.indicators.length} high-signal indicator(s). Review the indicators before blocking business-critical traffic.` + : 'The analyzer did not find strong malicious signals. Treat this as a low-risk result, not a guarantee of safety.'; + + const explanation = await db.explanations.create({ + explanation_type: assessment.indicators.length ? 'rule_match' : 'feature_importance', + explanation_text: explanationText, + top_indicators: JSON.stringify(assessment.indicators), + confidence: assessment.confidence, + generated_at: now, + detectionId: detection.id, + organizationsId: organizationId, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, { transaction }); + + await transaction.commit(); + + return { + submission: submission.get({ plain: true }), + detection: detection.get({ plain: true }), + explanation: explanation.get({ plain: true }), + indicators: assessment.indicators, + privacy: { + mode: privacyMode ? 'redacted_local_processing' : 'full_text_stored_by_request', + sha256: signalHash, + }, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async recentAssistantFindings(currentUser, limit = 8) { + const globalAccess = currentUser?.app_role?.globalAccess; + const organizationId = currentUser?.organizationsId + || currentUser?.organizations?.id + || currentUser?.organization?.id + || null; + const where = {}; + + if (!globalAccess) { + where[db.Sequelize.Op.or] = [ + { createdById: currentUser?.id || null }, + ]; + + if (organizationId) { + where[db.Sequelize.Op.or].push({ organizationsId: organizationId }); + } + } + + const rows = await db.threat_detections.findAll({ + where, + include: [ + { model: db.analysis_submissions, as: 'submission' }, + { model: db.explanations, as: 'explanations_detection' }, + ], + order: [['createdAt', 'desc']], + limit: Math.min(Number(limit) || 8, 25), + }); + + return rows.map((row) => row.get({ plain: true })); + } + }; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index b4cb2c6..55b30a6 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 7378cc4..1267c8d 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/threat-analyzer', + label: 'Threat Analyzer', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAlert ?? icon.mdiTable, + permissions: 'CREATE_THREAT_DETECTIONS' + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 54b612c..f5e26c7 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,166 @@ +import { mdiBrain, mdiLockCheckOutline, mdiRadar, mdiShieldAlert, mdiShieldCheck } from '@mdi/js' +import Head from 'next/head' +import Link from 'next/link' +import React, { ReactElement } from 'react' +import BaseButton from '../components/BaseButton' +import BaseIcon from '../components/BaseIcon' +import LayoutGuest from '../layouts/Guest' +import { getPageTitle } from '../config' -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; - - -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('background'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'AI Cybersecurity Assistant' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const capabilities = [ + { + icon: mdiShieldAlert, + title: 'Phishing triage', + text: 'Analyze emails, messages, and URLs for urgency, credential theft, impersonation, and suspicious destination signals.', + }, + { + icon: mdiRadar, + title: 'Malware signals', + text: 'Review file names, hashes, behavior notes, and network indicators for executable, persistence, and C2 patterns.', + }, + { + icon: mdiBrain, + title: 'Explainable scoring', + text: 'Every detection includes a risk score, verdict, and the top indicators that caused the assistant to flag it.', + }, + { + icon: mdiLockCheckOutline, + title: 'Privacy-first storage', + text: 'The analyzer stores redacted samples and SHA-256 fingerprints by default to reduce sensitive-data retention.', + }, +] +const Starter = () => { return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('AI Cybersecurity Assistant')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy +
+ + + + + SentinelAI -
+ + +
+
+
+
+
+
+ Local-first threat intelligence +
+

+ Explainable AI for phishing and malware defense. +

+

+ A modern cybersecurity assistant for individuals, schools, businesses, and public-sector teams: submit a suspicious message, + link, file signal, or network behavior and get a saved risk verdict with clear reasons. +

+
+ + +
+
+
+ 0 + external AI calls +
+
+ 100 + risk scale +
+
+ SHA-256 + fingerprinting +
+
+
+ +
+
+
+
+

Live workflow

+

Threat Analyzer

+
+ High +
+
+

Suspicious invoice request

+

“Urgent — verify your password within 24 hours. Open the attachment and enable macros.”

+
+
+
+ Risk score + 81/100 +
+
+
+
+
+
+ {['Credential request', 'Urgent pressure', 'Macro lure', 'Brand impersonation'].map((item) => ( +
+ {item} +
+ ))} +
+
+
+
+
+ +
+
+
+

MVP slice implemented

+

A complete first workflow, not just a dashboard.

+

+ Sign in to use the protected analyzer, create a detection, see the verdict, review explanations, and reopen recent analysis history. +

+
+
+ {capabilities.map((item) => ( +
+
+ +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+
+
+ +
+ © 2026 SentinelAI. Built for ethical, explainable, privacy-aware cybersecurity workflows.{' '} + + Login + +
- ); + ) } Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; + return {page} +} +export default Starter diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..005eb07 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; diff --git a/frontend/src/pages/threat-analyzer.tsx b/frontend/src/pages/threat-analyzer.tsx new file mode 100644 index 0000000..74ac008 --- /dev/null +++ b/frontend/src/pages/threat-analyzer.tsx @@ -0,0 +1,400 @@ +import { mdiBrain, mdiRadar, mdiShieldAlert } from '@mdi/js' +import Head from 'next/head' +import React, { FormEvent, ReactElement, useEffect, useMemo, useState } from 'react' +import axios from 'axios' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import LayoutAuthenticated from '../layouts/Authenticated' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import BaseIcon from '../components/BaseIcon' +import { getPageTitle } from '../config' + +type SubmissionType = 'email' | 'message' | 'url' | 'file' | 'network_traffic' + +type Indicator = { + label: string + weight: number +} + +type Detection = { + id: string + threat_type: string + severity: string + risk_score: number | string + verdict: string + summary: string + detected_at?: string + submission?: { + title?: string + submission_type?: string + processing_location?: string + sha256?: string + } + explanations_detection?: Array<{ + explanation_text?: string + top_indicators?: string + confidence?: number | string + }> +} + +type AnalysisResponse = { + detection: Detection + indicators: Indicator[] + explanation: { + explanation_text?: string + confidence?: number | string + } + privacy: { + mode: string + sha256: string + } +} + +const typeOptions: Array<{ value: SubmissionType; label: string; helper: string }> = [ + { value: 'email', label: 'Email', helper: 'Paste headers or body text.' }, + { value: 'message', label: 'Message', helper: 'Analyze SMS, chat, or DM copy.' }, + { value: 'url', label: 'Website / URL', helper: 'Inspect a suspicious link.' }, + { value: 'file', label: 'File metadata', helper: 'Use name, hash, and observed behavior.' }, + { value: 'network_traffic', label: 'Network activity', helper: 'Summarize ports, domains, and process behavior.' }, +] + +const severityStyles: Record = { + critical: 'bg-red-600 text-white ring-red-300', + high: 'bg-orange-500 text-white ring-orange-300', + medium: 'bg-amber-400 text-slate-950 ring-amber-200', + low: 'bg-sky-500 text-white ring-sky-200', + info: 'bg-emerald-500 text-white ring-emerald-200', +} + +const getIndicatorList = (detection?: Detection, fallback: Indicator[] = []) => { + if (fallback.length) return fallback + const raw = detection?.explanations_detection?.[0]?.top_indicators + if (!raw) return [] + + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch (error) { + console.error('Failed to parse threat indicators:', error) + return [] + } +} + +const ThreatAnalyzerPage = () => { + const [submissionType, setSubmissionType] = useState('email') + const [title, setTitle] = useState('Suspicious invoice request') + const [content, setContent] = useState( + 'Urgent: verify your Microsoft password within 24 hours or your account will be closed. Open the attachment and enable macros.', + ) + const [url, setUrl] = useState('') + const [fileName, setFileName] = useState('') + const [sha256, setSha256] = useState('') + const [networkActivity, setNetworkActivity] = useState('') + const [privacyMode, setPrivacyMode] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [result, setResult] = useState(null) + const [recent, setRecent] = useState([]) + const [selectedDetection, setSelectedDetection] = useState(null) + + const activeHelper = useMemo( + () => typeOptions.find((item) => item.value === submissionType)?.helper, + [submissionType], + ) + + const loadRecent = async () => { + const response = await axios.get('/threat_detections/assistant/recent?limit=8') + setRecent(Array.isArray(response.data?.rows) ? response.data.rows : []) + } + + useEffect(() => { + loadRecent().catch((error) => { + console.error('Failed to load recent detections:', error) + }) + }, []) + + const selectedOrResult = selectedDetection || result?.detection || null + const indicators = getIndicatorList(selectedDetection || undefined, selectedDetection ? [] : result?.indicators || []) + const riskScore = selectedOrResult ? Number(selectedOrResult.risk_score || 0) : 0 + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + setErrorMessage('') + setSelectedDetection(null) + setIsSubmitting(true) + + try { + const response = await axios.post('/threat_detections/analyze', { + data: { + submission_type: submissionType, + title, + content_text: content, + url, + fileName, + sha256, + network_activity: networkActivity, + privacy_mode: privacyMode, + }, + }) + setResult(response.data) + await loadRecent() + } catch (error) { + console.error('Threat analysis failed:', error) + if (axios.isAxiosError(error)) { + setErrorMessage(error.response?.data?.message || 'Analysis failed. Please check the input and try again.') + } else { + setErrorMessage('Analysis failed. Please check the input and try again.') + } + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + + {getPageTitle('Threat Analyzer')} + + + + + + +
+ +
+
+
+
+

Local-first triage

+

Analyze suspicious content

+

+ Submit an email, link, file signal, or network observation. The assistant creates a saved detection, + risk score, verdict, and explanation without calling an external AI provider. +

+
+
+
+ Explainable AI +
+

Indicators are stored with every result for review and tuning.

+
+
+ +
+
+ {typeOptions.map((option) => ( + + ))} +
+ +
+ + + +
+ +