diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ceaff1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# LLM API Configuration (OpenAI-compatible endpoint) +LLM_API_KEY=your_llm_api_key_here +LLM_API_BASE=http://localhost:11434/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0dbf2f2..a6b75d3 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,5 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +uv.lock +app_database/ \ No newline at end of file diff --git a/README.md b/README.md index 64beb35..b86c14b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,13 @@ Designed for operation in forensic-grade, air-gapped environments, the system en --- +## GUI Draft + +![Image](resources/gui-mockup.png) + +***Work in progress*** +*This is a mockup how the GUI would look, drafted with Claude.* + ## SYSTEM ARCHITECTURE & DESIGN PHILOSOPHY The design of Factum-Notes prioritizes integrity, low-latency input, and deployment flexibility. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28cb24c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "factum" +version = "0.0.0" +description = "Forensic note-taking application which utilizes PGP signatures on timestamped notes to ensure maximum integrity." +authors = [ + {name = "Mario Stöckl", email = "mstoeck3@hs-mittweida.de"} +] +readme = "README.md" +requires-python = "==3.13.*" +license = {text = "BSD-3-Clause"} + +dependencies = [ + "PySide6~=6.8.0", + "logging>=0.4.9.6", + "openai~=2.8.1", + "python-dotenv~=1.2.1", +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/factum"] + +[tool.uv.workspace] +members = [ + "factum", +] + +[project.scripts] +factum = "factum.main:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ac9c4a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,147 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements-txt +-e . +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +anyio==4.12.0 \ + --hash=sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0 \ + --hash=sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb + # via + # httpx + # openai +certifi==2025.11.12 \ + --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \ + --hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316 + # via + # httpcore + # httpx +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via tqdm +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via openai +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via openai +idna==3.11 \ + --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ + --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 + # via + # anyio + # httpx +jiter==0.12.0 \ + --hash=sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45 \ + --hash=sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7 \ + --hash=sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb \ + --hash=sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1 \ + --hash=sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de \ + --hash=sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed \ + --hash=sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6 \ + --hash=sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87 \ + --hash=sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b \ + --hash=sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e \ + --hash=sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3 \ + --hash=sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9 \ + --hash=sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4 \ + --hash=sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf \ + --hash=sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60 \ + --hash=sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44 \ + --hash=sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a \ + --hash=sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c \ + --hash=sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626 + # via openai +logging==0.4.9.6 \ + --hash=sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417 + # via factum +openai==2.8.1 \ + --hash=sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463 \ + --hash=sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f + # via factum +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d + # via openai +pydantic-core==2.41.5 \ + --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ + --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ + --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ + --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ + --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ + --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ + --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ + --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ + --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ + --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ + --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ + --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ + --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ + --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ + --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e + # via pydantic +pyside6==6.8.3 \ + --hash=sha256:31f390c961b54067ae41360e5ea3b340ce0e0e5feadea2236c28226d3b37edcc \ + --hash=sha256:5bf5153cab9484629315f57c56a9ad4b7d075b4dd275f828f7549abf712c590b \ + --hash=sha256:722dc0061d8ef6dbb8c0b99864f21e83a5b49ece1ecb2d0b890840d969e1e461 \ + --hash=sha256:8e53e2357bfbdee1fa86c48312bf637460a2c26d49e7af0b3fae2e179ccc7052 + # via factum +pyside6-addons==6.8.3 \ + --hash=sha256:67548f6db11f4e1b7e4b6efd9c3fc2e8d275188a7b2feac388961128572a6955 \ + --hash=sha256:6983d3b01fad53637bad5360930d5923509c744cc39704f9c1190eb9934e33da \ + --hash=sha256:7949a844a40ee10998eb2734e2c06c4c7182dfcd4c21cc4108a6b96655ebe59f \ + --hash=sha256:ea46649e40b9e6ab11a0da2da054d3914bff5607a5882885e9c3bc2eef200036 + # via pyside6 +pyside6-essentials==6.8.3 \ + --hash=sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07 \ + --hash=sha256:aa56c135db924ecfaf50088baf32f737d28027419ca5fee67c0c7141b29184e3 \ + --hash=sha256:b4f4823f870b5bed477d6f7b6a3041839b859f70abfd703cf53208c73c2fe4cd \ + --hash=sha256:fd57fa0c886ef99b3844173322c0023ec77cc946a0c9a0cdfbc2ac5c511053c1 + # via + # pyside6 + # pyside6-addons +python-dotenv==1.2.1 \ + --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ + --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 + # via factum +shiboken6==6.8.3 \ + --hash=sha256:295a003466ca2cccf6660e2f2ceb5e6cef4af192a48a196a32d46b6f0c9ec5cb \ + --hash=sha256:2b1a41348102952d2a5fbf3630bddd4d44112e18058b5e4cf505e51f2812429d \ + --hash=sha256:483efc7dd53c69147b8a8ade71f7619c79ffc683efcb1dc4f4cb6c40bb23d29b \ + --hash=sha256:bca3a94513ce9242f7d4bbdca902072a1631888e0aa3a8711a52cc5dbe93588f + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via openai +tqdm==4.67.1 \ + --hash=sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 \ + --hash=sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2 + # via openai +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # openai + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic diff --git a/resources/gui-mockup.jsx b/resources/gui-mockup.jsx new file mode 100644 index 0000000..e2efe9f --- /dev/null +++ b/resources/gui-mockup.jsx @@ -0,0 +1,844 @@ +import React, { useState } from 'react'; +import { ChevronRight, ChevronDown, Pin, X, Hash, User, Check, Send, Filter, Trash2 } from 'lucide-react'; + +export default function FactumNotesMockup() { + const [selectedCase, setSelectedCase] = useState('CASE-2025-001'); + const [selectedEvidence, setSelectedEvidence] = useState('HDD-001'); + const [activeCase, setActiveCase] = useState('CASE-2025-001'); + const [activeEvidence, setActiveEvidence] = useState('HDD-001'); + const [expandedCases, setExpandedCases] = useState({ 'CASE-2025-001': true }); + const [noteInput, setNoteInput] = useState(''); + const [showGoals, setShowGoals] = useState(true); + const [showIocs, setShowIocs] = useState(true); + const [llmActive, setLlmActive] = useState(false); + const [llmInput, setLlmInput] = useState(''); + const [llmMessages, setLlmMessages] = useState([ + { role: 'assistant', content: 'Ready to assist with analysis. Ask me about timeline correlation, IoC enrichment, or next investigation steps.' } + ]); + const [filterTag, setFilterTag] = useState(null); + + // Mock data + const cases = { + 'CASE-2025-001': { + name: 'Financial Fraud Investigation', + evidences: ['HDD-001', 'USB-STICK-001', 'MEMORY-DUMP-001'] + }, + 'CASE-2024-089': { + name: 'Corporate Espionage', + evidences: ['LAPTOP-001', 'PHONE-BACKUP-001'] + } + }; + + const [notes, setNotes] = useState([ + { + id: 1, + caseId: 'CASE-2025-001', + evidenceId: 'HDD-001', + timestamp: '2025-12-22 14:32:15 UTC', + content: 'Initial analysis of disk image reveals potential data exfiltration. Found suspicious PowerShell script at C:\\Users\\john.doe\\AppData\\Local\\Temp\\export.ps1. Script contains Base64-encoded payload attempting to connect to 192.168.45.123:4444 and download file from https://malicious-domain.com/payload.exe. MD5 hash of script: 5d41402abc4b2a76b9719d911017c592', + tags: ['initial-analysis', 'powershell', 'network-activity'], + signatures: ['Alice Johnson', 'Bob Smith'], + iocs: ['192.168.45.123:4444', 'https://malicious-domain.com/payload.exe', '5d41402abc4b2a76b9719d911017c592'], + signed: true, + pinned: false + }, + { + id: 2, + caseId: 'CASE-2025-001', + evidenceId: 'HDD-001', + timestamp: '2025-12-22 15:45:02 UTC', + content: 'Registry analysis shows persistence mechanism established via Run key. Malware creates scheduled task named "SystemUpdate" executing C:\\Windows\\Temp\\svchost.exe every 30 minutes. SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + tags: ['persistence', 'registry', 'scheduled-task'], + signatures: ['Alice Johnson'], + iocs: ['C:\\Windows\\Temp\\svchost.exe', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'], + signed: true, + pinned: false + }, + { + id: 3, + caseId: 'CASE-2025-001', + evidenceId: 'HDD-001', + timestamp: '2025-12-22 16:20:38 UTC', + content: 'Browser history extraction complete. User visited cryptocurrency exchange wallet at 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa multiple times between Dec 15-20. Timeline correlates with unauthorized transfers.', + tags: ['timeline', 'browser-history', 'cryptocurrency'], + signatures: ['Alice Johnson'], + iocs: ['1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'], + signed: true, + pinned: true + }, + { + id: 4, + caseId: 'CASE-2025-001', + evidenceId: 'USB-STICK-001', + timestamp: '2025-12-22 17:10:22 UTC', + content: 'USB device analysis reveals autorun.inf configured to execute malicious payload. Device contains encrypted archive with password hint referencing internal project codename.', + tags: ['usb-analysis', 'autorun', 'encryption'], + signatures: ['Bob Smith'], + iocs: [], + signed: true, + pinned: false + }, + { + id: 5, + caseId: 'CASE-2024-089', + evidenceId: 'LAPTOP-001', + timestamp: '2025-12-21 10:15:00 UTC', + content: 'Corporate laptop shows evidence of unauthorized remote access tool installation. RustDesk client configured with external server 203.0.113.50:21116', + tags: ['remote-access', 'corporate', 'initial-analysis'], + signatures: ['Alice Johnson'], + iocs: ['203.0.113.50:21116'], + signed: true, + pinned: false + }, + { + id: 6, + caseId: 'CASE-2025-001', + evidenceId: null, // Case-level note + timestamp: '2025-12-22 13:00:00 UTC', + content: 'Case opened: Financial fraud investigation involving potential cryptocurrency theft. Initial evidence suggests internal employee involvement based on access patterns.', + tags: ['case-overview', 'fraud', 'cryptocurrency'], + signatures: ['Alice Johnson'], + iocs: [], + signed: true, + pinned: false + }, + { + id: 7, + caseId: 'CASE-2025-001', + evidenceId: null, // Case-level note + timestamp: '2025-12-22 18:00:00 UTC', + content: 'Daily summary: Evidence from multiple devices shows coordinated exfiltration attempt. Recommend expanding investigation to include network logs and employee workstation forensics.', + tags: ['summary', 'coordination'], + signatures: ['Alice Johnson'], + iocs: [], + signed: true, + pinned: false + } + ]); + + const [showNestedNotes, setShowNestedNotes] = useState(true); + + const [pinnedNotes, setPinnedNotes] = useState([ + { + id: 'pin-1', + content: 'CRITICAL: Check all PowerShell execution logs between Dec 15-20', + timestamp: '2025-12-22 13:00:00 UTC' + } + ]); + + const investigativeGoals = [ + 'Identify initial infection vector and timeline', + 'Map complete network of compromised systems', + 'Determine data exfiltration extent and destination', + 'Establish attribution indicators' + ]; + + const toggleCase = (caseId) => { + setExpandedCases(prev => ({ ...prev, [caseId]: !prev[caseId] })); + }; + + const extractIocs = (text) => { + const iocs = []; + + // IPv4 with port + const ipv4Pattern = /\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g; + const ipMatches = text.match(ipv4Pattern); + if (ipMatches) iocs.push(...ipMatches); + + // URLs + const urlPattern = /https?:\/\/[^\s]+/g; + const urlMatches = text.match(urlPattern); + if (urlMatches) iocs.push(...urlMatches); + + // MD5 + const md5Pattern = /\b[a-f0-9]{32}\b/gi; + const md5Matches = text.match(md5Pattern); + if (md5Matches) iocs.push(...md5Matches); + + // SHA256 + const sha256Pattern = /\b[a-f0-9]{64}\b/gi; + const sha256Matches = text.match(sha256Pattern); + if (sha256Matches) iocs.push(...sha256Matches); + + // Bitcoin addresses (simplified) + const btcPattern = /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/g; + const btcMatches = text.match(btcPattern); + if (btcMatches) iocs.push(...btcMatches); + + // File paths + const pathPattern = /[A-Z]:\\(?:[^\s\\]+\\)*[^\s\\]+\.[a-z]{2,4}/gi; + const pathMatches = text.match(pathPattern); + if (pathMatches) iocs.push(...pathMatches); + + return [...new Set(iocs)]; + }; + + const extractTags = (text) => { + const tagPattern = /#([a-z0-9-]+)/gi; + const matches = text.match(tagPattern); + return matches ? [...new Set(matches.map(t => t.substring(1).toLowerCase()))] : []; + }; + + const addNote = () => { + if (!noteInput.trim()) return; + + const now = new Date(); + const timestamp = now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC'; + const iocs = extractIocs(noteInput); + const tags = extractTags(noteInput); + + const newNote = { + id: Math.max(...notes.map(n => n.id), 0) + 1, + caseId: selectedCase, + evidenceId: selectedEvidence, + timestamp, + content: noteInput, + tags, + signatures: ['Alice Johnson'], + iocs, + signed: true, + pinned: false + }; + + setNotes([...notes, newNote]); + setNoteInput(''); + }; + + const togglePin = (noteId) => { + setNotes(notes.map(note => + note.id === noteId ? { ...note, pinned: !note.pinned } : note + )); + }; + + const deleteNote = (noteId) => { + setNotes(notes.filter(n => n.id !== noteId)); + }; + + const sendLlmMessage = () => { + if (!llmInput.trim()) return; + + const userMsg = { role: 'user', content: llmInput }; + setLlmMessages([...llmMessages, userMsg]); + + // Simulate AI response + setTimeout(() => { + let response = ''; + const input = llmInput.toLowerCase(); + + if (input.includes('timeline') || input.includes('chronology')) { + response = 'Based on the timestamps, the attack sequence appears to be: 1) Initial compromise via PowerShell script (14:32), 2) Persistence establishment through registry modification (15:45), 3) Data exfiltration to cryptocurrency wallet (16:20). Consider examining system logs between these timestamps for additional pivot points.'; + } else if (input.includes('ioc') || input.includes('indicator')) { + response = 'I\'ve identified 6 unique IoCs across your notes. The IP address 192.168.45.123:4444 appears to be a C2 server. I recommend: 1) Checking if this IP appears in other evidence items, 2) Searching for the malicious domain in DNS logs, 3) Validating the file hashes against VirusTotal.'; + } else if (input.includes('next') || input.includes('recommend')) { + response = 'Recommended next steps: 1) Analyze the PowerShell execution history for the timeframe Dec 15-20, 2) Extract and decrypt the USB archive if possible, 3) Cross-reference the cryptocurrency wallet address with blockchain explorers for transaction analysis, 4) Check network logs for communication with 192.168.45.123.'; + } else { + response = 'I can help analyze your notes, correlate timelines, enrich IoCs, or suggest investigation steps. What specific aspect would you like assistance with?'; + } + + const aiMsg = { role: 'assistant', content: response }; + setLlmMessages(prev => [...prev, aiMsg]); + }, 500); + + setLlmInput(''); + }; + + const handleKeyDown = (e, callback) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + callback(); + } + }; + + // Filter notes based on selection and tag filter + const filteredNotes = notes.filter(note => { + const matchesCase = note.caseId === selectedCase; + const matchesTag = !filterTag || note.tags.includes(filterTag); + + if (!matchesCase || !matchesTag) return false; + + // If viewing a specific evidence, only show notes from that evidence + if (selectedEvidence) { + return note.evidenceId === selectedEvidence; + } + + // If viewing case-level (no evidence selected) + // Show case-level notes always + if (!note.evidenceId) { + return true; + } + + // Show evidence notes only if nested toggle is on + return showNestedNotes; + }).sort((a, b) => { + // Sort chronologically by timestamp + return new Date(a.timestamp) - new Date(b.timestamp); + }); + + // Get all unique tags from filtered notes + const allTags = [...new Set(filteredNotes.flatMap(note => note.tags))]; + + // Get extracted IoCs from filtered notes and organize by type + const iocsByType = filteredNotes.reduce((acc, note) => { + note.iocs.forEach(ioc => { + let type = 'Unknown'; + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(ioc)) type = 'IPv4'; + else if (/^https?:\/\//.test(ioc)) type = 'URL'; + else if (/^[a-f0-9]{32}$/i.test(ioc)) type = 'MD5'; + else if (/^[a-f0-9]{64}$/i.test(ioc)) type = 'SHA256'; + else if (/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(ioc)) type = 'Bitcoin'; + else if (/^[A-Z]:\\/.test(ioc)) type = 'File Path'; + + if (!acc[type]) { + acc[type] = []; + } + if (!acc[type].find(item => item === ioc)) { + acc[type].push(ioc); + } + }); + return acc; + }, {}); + + // Get pinned notes from the currently selected case (not filtered by evidence) + const displayPinnedNotes = notes.filter(n => n.pinned && n.caseId === selectedCase); + + // State for IoC tree expansion + const [expandedIocTypes, setExpandedIocTypes] = useState({}); + + const toggleIocType = (type) => { + setExpandedIocTypes(prev => ({ ...prev, [type]: !prev[type] })); + }; + + const highlightIocs = (text, iocs) => { + if (!iocs || iocs.length === 0) return text; + + let result = text; + const parts = []; + let lastIndex = 0; + + // Sort IOCs by their position in text + const positions = []; + iocs.forEach(ioc => { + const index = result.indexOf(ioc); + if (index !== -1) { + positions.push({ ioc, index }); + } + }); + positions.sort((a, b) => a.index - b.index); + + positions.forEach(({ ioc, index }) => { + if (index >= lastIndex) { + parts.push(result.substring(lastIndex, index)); + parts.push({ioc}); + lastIndex = index + ioc.length; + } + }); + parts.push(result.substring(lastIndex)); + + return parts; + }; + + return ( +
+ {/* Menu Bar */} +
+ FACTUM-NOTES + + + + +
+ + {/* Main Content Area */} +
+ {/* Left Sidebar - Case/Evidence Tree */} +
+
+

Case Structure

+

+ = Active for CLI notes +

+
+ +
+ {Object.entries(cases).map(([caseId, caseData]) => ( +
+
{ + setSelectedCase(caseId); + setSelectedEvidence(null); + toggleCase(caseId); + }} + > + {expandedCases[caseId] ? + : + + } + { + e.stopPropagation(); + setActiveCase(caseId); + setActiveEvidence(null); + }} + title={activeCase === caseId && !activeEvidence ? "Active for CLI notes" : "Set as active for CLI notes"} + /> + {caseId} +
+ + {expandedCases[caseId] && ( +
+
{caseData.name}
+ {caseData.evidences.map(evidence => ( +
{ + setSelectedCase(caseId); + setSelectedEvidence(evidence); + }} + > + { + e.stopPropagation(); + setActiveCase(caseId); + setActiveEvidence(evidence); + }} + title={activeCase === caseId && activeEvidence === evidence ? "Active for CLI notes" : "Set as active for CLI notes"} + /> + {evidence} +
+ ))} +
+ )} +
+ ))} +
+ +
+ + +
+
+ + {/* Central Content Area */} +
+ {/* Filter Bar */} +
+
+
+ + Filter by tag: + + {filterTag && ( + + )} +
+ {!selectedEvidence && ( + + )} +
+ {filteredNotes.length} note{filteredNotes.length !== 1 ? 's' : ''} +
+ + {/* Notes Display */} +
+ {filteredNotes.length === 0 ? ( +
+ No notes for this selection +
+ ) : ( + filteredNotes.map((note, idx) => { + // Determine if this is a nested note (evidence note when viewing case-level) + const isNested = !selectedEvidence && note.evidenceId; + + // For tree structure, check if next note is also nested + const nextNote = filteredNotes[idx + 1]; + const isLastNested = isNested && (!nextNote || !nextNote.evidenceId); + const treeSymbol = isNested ? (isLastNested ? '└──' : '├──') : ''; + + return ( +
+ {/* Timestamp and Evidence Badge */} +
+
+ {isNested && ( + <> + {treeSymbol} + + {note.evidenceId} + + + )} + {note.timestamp} +
+
+ togglePin(note.id)} + /> + { + if (window.confirm('Delete this note?')) { + deleteNote(note.id); + } + }} + /> +
+
+ + {/* Content */} +
+ {highlightIocs(note.content, note.iocs)} +
+ + {/* Tags */} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map(tag => ( + setFilterTag(tag)} + > + #{tag} + + ))} +
+ )} + + {/* Signatures */} +
+ + Signed by: + {note.signatures.map((signer, idx) => ( + + + + {signer} + + + ))} +
+
+ ); + }) + )} +
+ + {/* Input Area */} +
+