diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..165f87a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.next +.wrangler +dist +node_modules +backend/.pytest_cache +backend/**/__pycache__ +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..77d4dda --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +POSTGRES_PASSWORD=replace-with-a-database-password +JWT_SECRET=replace-with-at-least-24-random-characters +COOKIE_SECURE=false +ENVIRONMENT=development +BOOTSTRAP_ADMIN_EMAIL=admin@example.com +BOOTSTRAP_ADMIN_NAME=CaseLens Administrator +BOOTSTRAP_ADMIN_PASSWORD=replace-with-a-long-admin-password +MAX_UPLOAD_BYTES=104857600 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf20947 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm audit --audit-level=high + + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements-dev.txt + - run: sudo apt-get update && sudo apt-get install -y libyara-dev + - run: python -m pip install --upgrade pip + - run: python -m pip install -r backend/requirements-dev.txt + - run: python -m pytest tests -q + working-directory: backend + + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: cp .env.example .env + - run: docker compose config --quiet + - run: docker compose build api web diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6b15c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +.venv/ +node_modules/ +.next/ +.vinext/ +.wrangler/ +dist/ +backend/.pytest_cache/ +backend/**/__pycache__/ +backend/*.db +coverage.xml +*.pyc diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..d819dc6 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime +ENV NODE_ENV=production +WORKDIR /app +COPY --from=build /app ./ +EXPOSE 3000 +CMD ["npm", "run", "start", "--", "--host", "0.0.0.0"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86bba9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chase Stone + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1a2e6a --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# CaseLens + +CaseLens is a security operations case manager for file and network investigations. It brings Crucible and PacketLens into one workflow so a team can collect evidence, queue analysis, correlate indicators, document decisions, and produce reports without passing artifacts between separate tools. + +## What it covers + +- Incident ownership, severity, status, notes, and evidence +- File and PCAP uploads with SHA-256 verification +- Background analysis through Celery and Redis +- Static file inspection through Crucible +- PCAP inspection through PacketLens +- Correlation of IP addresses, domains, hashes, and MITRE ATT&CK techniques +- Admin, analyst, and read-only access levels +- Append-only, hash-chained audit history +- Executive and technical HTML reports +- PostgreSQL persistence, Docker deployment, and CI checks + +## Run it with Docker + +Requirements: Docker Desktop or Docker Engine with Compose. + +```powershell +Copy-Item .env.example .env +``` + +Edit `.env` and replace the database password, JWT secret, and bootstrap administrator password. For a production deployment, set `ENVIRONMENT=production`, `COOKIE_SECURE=true`, and serve CaseLens over HTTPS. + +```powershell +docker compose up --build +``` + +Open `http://localhost:8080` and sign in with the bootstrap administrator account from `.env`. The initial account is only created when the users table is empty. + +## Investigation flow + +1. Create an incident and assign an analyst. +2. Add investigation notes and upload evidence. +3. Queue an analysis from the evidence panel. +4. Review the job output and correlated indicators. +5. Export an executive summary or technical report. + +Crucible is used for static file inspection only. Its dynamic execution path is intentionally disabled in the shared worker. PacketLens handles supported PCAP and PCAPNG captures. The worker stores structured findings in PostgreSQL and keeps original evidence in the configured evidence volume. + +## Roles + +| Role | Access | +| --- | --- | +| Admin | Full investigation access, account management, assignment, audit history, and chain verification | +| Analyst | Create and update incidents, add notes, upload evidence, queue jobs, and export reports | +| Read only | Review incidents, evidence metadata, findings, correlations, and reports | + +## Local development + +The frontend requires Node.js 22 or newer. The API targets Python 3.12. + +```powershell +npm install +npm run dev +``` + +In another terminal: + +```powershell +python -m venv .venv +.\.venv\Scripts\pip.exe install -r backend\requirements-dev.txt +$env:DATABASE_URL = "postgresql+psycopg://caselens:password@localhost:5432/caselens" +$env:REDIS_URL = "redis://localhost:6379/0" +.\.venv\Scripts\python.exe -m uvicorn app.main:app --app-dir backend --reload +``` + +Start a worker after PostgreSQL and Redis are available: + +```powershell +.\.venv\Scripts\python.exe -m celery --workdir backend -A app.celery_app:celery_app worker --loglevel=INFO +``` + +Run the checks: + +```powershell +npm test +npm run lint +.\.venv\Scripts\python.exe -m pytest backend\tests -q +docker compose config +``` + +## Project layout + +- `app/`: analyst web interface +- `backend/app/`: API, authorization, analysis adapters, audit chain, and reports +- `backend/migrations/`: PostgreSQL schema and audit immutability trigger +- `deploy/`: gateway configuration and security headers +- `docs/`: architecture and deployment security notes +- `.github/workflows/ci.yml`: frontend, backend, and Compose validation + +See [architecture.md](docs/architecture.md) for the service boundaries and [security.md](docs/security.md) before exposing the platform outside a trusted network. + +## Upstream analyzers + +The worker installs fixed revisions of [Crucible](https://github.com/chasejstone/Crucible) and [PacketLens](https://github.com/chasejstone/PacketLens). Update those pins deliberately, then rerun the backend tests and analyze representative samples before deployment. + +## License + +MIT diff --git a/app/case-lens-app.tsx b/app/case-lens-app.tsx new file mode 100644 index 0000000..5806d3e --- /dev/null +++ b/app/case-lens-app.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; + +type Role = "admin" | "analyst" | "read_only"; +type User = { id: string; email: string; display_name: string; role: Role; is_active: boolean }; +type Incident = { + id: string; + case_number: string; + title: string; + summary: string; + status: "open" | "in_progress" | "closed"; + severity: "low" | "medium" | "high" | "critical"; + assigned_to: User | null; + created_by: User; + created_at: string; + updated_at: string; +}; +type Note = { id: string; body: string; created_at: string; author: User }; +type Evidence = { + id: string; + original_name: string; + content_type: string; + size_bytes: number; + sha256: string; + kind: "file" | "pcap"; + created_at: string; + uploaded_by: User; +}; +type IncidentDetail = Incident & { notes: Note[]; evidence: Evidence[] }; +type Job = { + id: string; + evidence_id: string; + status: "queued" | "running" | "succeeded" | "failed"; + error: string | null; + findings: Record | null; + created_at: string; + started_at: string | null; + completed_at: string | null; +}; +type Correlation = { + entity_id: string; + type: "ip" | "domain" | "hash" | "mitre"; + value: string; + incident_count: number; + incidents: { id: string; case_number: string; title: string }[]; +}; +type Audit = { + id: number; + occurred_at: string; + actor_id: string | null; + action: string; + object_type: string; + object_id: string; + entry_hash: string; +}; +type Tab = "overview" | "incidents" | "correlations" | "team" | "audit"; + +async function api(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json"); + const response = await fetch(path, { ...options, headers, credentials: "include" }); + if (!response.ok) { + const payload = await response.json().catch(() => ({ detail: "Request failed" })); + throw new Error(payload.detail || `Request failed with ${response.status}`); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +function formatDate(value: string | null): string { + if (!value) return "Not started"; + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 ** 2).toFixed(1)} MB`; +} + +export function CaseLensApp() { + const [user, setUser] = useState(null); + const [checkingSession, setCheckingSession] = useState(true); + const [incidents, setIncidents] = useState([]); + const [jobs, setJobs] = useState([]); + const [correlations, setCorrelations] = useState([]); + const [audit, setAudit] = useState([]); + const [team, setTeam] = useState([]); + const [tab, setTab] = useState("overview"); + const [search, setSearch] = useState(""); + const [selectedIncident, setSelectedIncident] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + + const canWrite = user?.role === "admin" || user?.role === "analyst"; + + const loadCore = useCallback(async () => { + const [incidentRows, jobRows, correlationRows] = await Promise.all([ + api("/api/incidents"), + api("/api/jobs?limit=40"), + api("/api/correlations?minimum_incidents=1&limit=40"), + ]); + setIncidents(incidentRows); + setJobs(jobRows); + setCorrelations(correlationRows); + }, []); + + const refreshSelected = useCallback(async (id: string) => { + const detail = await api(`/api/incidents/${id}`); + setSelectedIncident(detail); + }, []); + + const openIncident = useCallback((id: string) => { + api(`/api/incidents/${id}`) + .then(setSelectedIncident) + .catch((reason) => setError(reason.message)); + }, []); + + useEffect(() => { + api("/api/auth/me") + .then(async (current) => { + setUser(current); + await Promise.all([ + loadCore(), + current.role === "admin" ? api("/api/users").then(setTeam) : Promise.resolve(), + ]); + }) + .catch(() => setUser(null)) + .finally(() => setCheckingSession(false)); + }, [loadCore]); + + useEffect(() => { + if (!user) return; + const timer = window.setInterval(() => { + api("/api/jobs?limit=40").then(setJobs).catch(() => undefined); + }, 5000); + return () => window.clearInterval(timer); + }, [user]); + + useEffect(() => { + if (tab === "audit" && user?.role === "admin") { + api("/api/audit?limit=200").then(setAudit).catch((reason) => setError(reason.message)); + } + if (tab === "team" && user?.role === "admin") { + api("/api/users").then(setTeam).catch((reason) => setError(reason.message)); + } + }, [tab, user]); + + const filteredIncidents = useMemo(() => { + const needle = search.trim().toLowerCase(); + if (!needle) return incidents; + return incidents.filter((incident) => + [incident.case_number, incident.title, incident.summary, incident.assigned_to?.display_name || ""] + .join(" ") + .toLowerCase() + .includes(needle), + ); + }, [incidents, search]); + + const metrics = useMemo( + () => ({ + open: incidents.filter((incident) => incident.status !== "closed").length, + critical: incidents.filter((incident) => incident.severity === "critical").length, + running: jobs.filter((job) => job.status === "queued" || job.status === "running").length, + indicators: correlations.length, + }), + [incidents, jobs, correlations], + ); + + function flash(message: string) { + setNotice(message); + window.setTimeout(() => setNotice(null), 3500); + } + + async function logout() { + await api("/api/auth/logout", { method: "POST" }); + setUser(null); + } + + if (checkingSession) { + return
CL

Opening the operations workspace

; + } + + if (!user) { + return { + setUser(current); + await Promise.all([ + loadCore(), + current.role === "admin" ? api("/api/users").then(setTeam) : Promise.resolve(), + ]); + }} />; + } + + return ( +
+ + +
+
+

Investigation workspace

{tab === "overview" ? "Operational picture" : tab.replace("_", " ")}

+
+ + {canWrite && } +
+
+ + {notice &&
{notice}
} + {error &&
{error}
} + + {tab === "overview" && ( + + )} + {tab === "incidents" && ( + + )} + {tab === "correlations" && } + {tab === "team" && user.role === "admin" && ( + setTeam(await api("/api/users"))} flash={flash} setError={setError} /> + )} + {tab === "audit" && user.role === "admin" && } +
+ + {showCreate && setShowCreate(false)} onCreated={async (incident) => { setShowCreate(false); await loadCore(); openIncident(incident.id); flash(`${incident.case_number} created`); }} setError={setError} />} + {selectedIncident && setSelectedIncident(null)} onChanged={async () => { await refreshSelected(selectedIncident.id); await loadCore(); }} flash={flash} setError={setError} />} +
+ ); +} + +function Login({ onLogin }: { onLogin: (user: User) => Promise }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setError(null); + try { + const current = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }); + await onLogin(current); + } catch (reason) { setError((reason as Error).message); } finally { setBusy(false); } + } + + return
CL

CaseLens security operations

Manage security investigations in one place.

Review incidents, evidence, analysis jobs, shared indicators, and audit history.

File analysis Crucible static inspectionPacket analysis PacketLens capture reviewAudit trail Hash-chained case activity

Authorized access

Sign in to CaseLens

Use the account provided by your administrator.

{error &&

{error}

}
Access is logged. Contact an administrator if your role is incorrect.
; +} + +function NavButton({ active, label, badge, onClick }: { active: boolean; label: string; badge?: number; onClick: () => void }) { + return ; +} + +function Overview({ metrics, incidents, jobs, onOpen }: { metrics: { open: number; critical: number; running: number; indicators: number }; incidents: Incident[]; jobs: Job[]; onOpen: (id: string) => void }) { + return
; +} + +function Metric({ label, value, note, tone }: { label: string; value: number; note: string; tone: string }) { + return

{label}

{String(value).padStart(2, "0")}{note}
; +} + +function PanelTitle({ title, detail }: { title: string; detail: string }) { + return

{title}

{detail}

; +} + +function IncidentTable({ incidents, onOpen, expanded = false }: { incidents: Incident[]; onOpen: (id: string) => void; expanded?: boolean }) { + return
{expanded && }{incidents.map((incident) => onOpen(incident.id)} tabIndex={0} onKeyDown={(event) => event.key === "Enter" && onOpen(incident.id)}>)}
CaseIncidentSeverityStatusAssignedUpdated
{incident.case_number}{incident.title}{incident.summary || "No summary"}{incident.severity}{incident.status.replace("_", " ")}{incident.assigned_to?.display_name || "Unassigned"}{formatDate(incident.updated_at)}
{incidents.length === 0 &&
No incidents match this view.
}
; +} + +function JobList({ jobs }: { jobs: Job[] }) { + return
{jobs.map((job) =>
{job.status === "running" ? "RUN" : job.status.slice(0, 3).toUpperCase()}
Evidence analysis{job.error || formatDate(job.created_at)}
{job.id.slice(0, 8)}
)}{jobs.length === 0 &&
No analysis jobs yet.
}
; +} + +function CorrelationTable({ rows, onOpen }: { rows: Correlation[]; onOpen: (id: string) => void }) { + return
{rows.map((row) => )}
TypeIndicatorIncidentsCases
{row.type}{row.value}{row.incident_count}
{row.incidents.map((incident) => )}
{rows.length === 0 &&
Correlations appear after evidence analysis completes.
}
; +} + +function AuditPanel({ rows }: { rows: Audit[] }) { + return
{rows.map((row) =>
{row.action}{row.object_type} {row.object_id.slice(0, 14)}
{row.entry_hash.slice(0, 14)}
)}
; +} + +function TeamPanel({ users, onCreated, flash, setError }: { users: User[]; onCreated: () => Promise; flash: (value: string) => void; setError: (value: string) => void }) { + const [showForm, setShowForm] = useState(false); + return
{users.map((member) =>
{member.display_name.split(" ").map((part) => part[0]).join("").slice(0, 2)}
{member.display_name}{member.email}
{member.role.replace("_", " ")}
)}

Add team member

Create an analyst, administrator, or read-only account.

{showForm && { await onCreated(); flash(`${user.display_name} added`); setShowForm(false); }} setError={setError} />}
; +} + +function UserForm({ onCreated, setError }: { onCreated: (user: User) => Promise; setError: (value: string) => void }) { + async function submit(event: FormEvent) { event.preventDefault(); const data = new FormData(event.currentTarget); try { const user = await api("/api/users", { method: "POST", body: JSON.stringify({ display_name: data.get("display_name"), email: data.get("email"), password: data.get("password"), role: data.get("role") }) }); await onCreated(user); } catch (reason) { setError((reason as Error).message); } } + return
; +} + +function CreateIncident({ users, isAdmin, onClose, onCreated, setError }: { users: User[]; isAdmin: boolean; onClose: () => void; onCreated: (incident: Incident) => Promise; setError: (value: string) => void }) { + async function submit(event: FormEvent) { event.preventDefault(); const data = new FormData(event.currentTarget); try { const incident = await api("/api/incidents", { method: "POST", body: JSON.stringify({ title: data.get("title"), summary: data.get("summary"), severity: data.get("severity"), assigned_to_id: data.get("assigned_to_id") || null }) }); await onCreated(incident); } catch (reason) { setError((reason as Error).message); } } + return
event.target === event.currentTarget && onClose()}>

New investigation

Create incident