diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index bd3d243..504d9a3 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -39,11 +39,11 @@ Plan and execute red‑team operations and measure defensive effectiveness (dete ## Core Entities -- Operation { name, description, tags[], crownJewels[], threatActor?, status, visibility, accessGroups[], techniques[] } -- Technique { tactic, technique, subTechnique?, description, start/end, sourceIp?, targetSystem?, crownJewelTargeted?, crownJewelCompromised?, tools[] } +- Operation { name, description, tags[], targets[], threatActor?, status, visibility, accessGroups[], techniques[] } +- Technique { tactic, technique, subTechnique?, description, start/end, sourceIp?, targetSystem?, targetEngagements[], tools[] } - Outcome { type: DETECTION | PREVENTION | ATTRIBUTION, status, tools[]/logSources[], timestamp? } - ThreatActor { name, description, topThreat, mitreTechniques[] } -- CrownJewel { name, description } +- Target { name, description, isCrownJewel } - Tool { name, type: DEFENSIVE | OFFENSIVE, category } - ToolCategory { name, type } - Tag { name, description, color } @@ -61,16 +61,16 @@ Plan and execute red‑team operations and measure defensive effectiveness (dete - Filters: search, status (All/Planning/Active/Completed/Cancelled), selectable tag chips. - List: neutral operation cards; click to open detail. Card delete uses ConfirmModal. -- Create/Edit Operation: elevated modal with name/description, optional threat actor, dates, tags, crown jewels. +- Create/Edit Operation: elevated modal with name/description, optional threat actor, dates, tags, targets (mark crown jewels). #### Operation Detail -- Header: name, description, tags, threat actor, crown jewels, status. +- Header: name, description, tags, threat actor, targets (CJ flagged), status. - KPIs: detection/prevention/attribution (%) computed from graded outcomes (excludes N/A). - Tabs - Techniques: drag‑and‑drop list; InlineActions for edit/delete. Technique Editor (elevated) with: - Overview: tactic/technique (sub‑tech aware) + description. - - Execution: start/end (datetime with “Now”), source IP, target system, offensive tools, crown‑jewel flags. + - Execution: start/end (datetime with “Now”), source IP, target system, offensive tools, target selection + crown jewel flag. - Outcomes: grade detection/prevention/attribution; add tools/log sources; optional timestamps. - ATT&CK Heatmap: full MITRE matrix with executed highlighting; sub‑tech expansion; ops/all toggle available in analytics view. - Attack Flow: simple flow of techniques (editors can organize). @@ -90,7 +90,7 @@ Unified pattern across tabs: SettingsHeader + EntityListCard + EntityModal; Inli - Users: create/edit; role picker; delete via ConfirmModal. - Groups: create/edit; manage membership; one Tag per Group; delete via ConfirmModal. -- Taxonomy: Tags, Tool Categories (by type), Tools, Threat Actors (attach ATT&CK techniques), Crown Jewels, Log Sources. +- Taxonomy: Tags, Tool Categories (by type), Tools, Threat Actors (attach ATT&CK techniques), Targets (with crown jewel toggle), Log Sources. - Data: overview metrics; export/import a combined operations + taxonomy backup (always replaces existing data); clear-all confirmation. ## Data & Validation @@ -102,4 +102,4 @@ Unified pattern across tabs: SettingsHeader + EntityListCard + EntityModal; Inli - Initialization runs before the server starts via `scripts/init.ts`. It ensures an admin account exists and seeds MITRE data if empty by parsing the STIX bundle at `data/mitre/enterprise-attack.json` through `src/lib/mitreStix.ts` (no generated processed file). - Development (SQLite): after deleting the DB, run `npm run db:push` once to create tables. Then start the app; initialization will seed admin + MITRE. Data persists across restarts. -- Production: the app never runs `db push`. Provision schema using migrations (`npm run db:migrate`) or ship a pre-created SQLite file and persist it. Initialization still creates the admin and seeds MITRE if tables are present and empty. +- Production: the app never runs `db push`. Provision schema ahead of time (for now run `npx prisma db push` during deploys or ship a pre-created SQLite file and persist it). Initialization still creates the admin and seeds MITRE if tables are present and empty. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1fb11ce..150eca8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,7 +11,7 @@ This guide walks through the first workflow after you launch the application. Fo - Log Sources. Examples: Sysmon, Windows Event Logs, etc. These are used for recording attribution outcomes. - Optional: - Threat Actors. You can enter them manually or use the import option to pull in techniques directly from MITRE ATT&CK. - - Crown Jewels. Examples: Production DB, Source Code Repo, etc. + - Targets. Examples: Production DB, Source Code Repo, employee credentials, etc. Mark the ones that are crown jewels so analytics continue to highlight them. - Tags. Examples: Stealth, Purple Team, etc. These can be applied to operations and used for filtering lists analytics.

@@ -38,7 +38,7 @@ This guide walks through the first workflow after you launch the application. Fo - From MITRE campaign: imports the techniques from a MITRE ATT&CK campaign - Provide the name, description, status - Set the start and end dates. These dates drive analytics such as trends and duration metrics, so keep them accurate. -- Optionally, configure tags, crown jewels, threat actor being emulated, and group access restrictions. +- Optionally, configure tags, targets (with crown jewel markers), threat actor being emulated, and group access restrictions.

@@ -53,7 +53,7 @@ This guide walks through the first workflow after you launch the application. Fo - Use the Tactic/Technique pickers to choose from the catalog - Fill in an optional description - Execution tab: - - Timing, execution details, offensive tooling, and crown jewel targeting + - Timing, execution details, offensive tooling, and target selection with per-target outcome tracking (mark whether each chosen asset was compromised) - Outcomes tab: - Was the technique detected, prevented, or attributed later during IR? - What tooling and log sources were involved in successful outcomes or SHOULD HAVE BEEN involved in failed outcomes? diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c77015..f2014c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,15 +145,18 @@ model ThreatActor { @@index([topThreat]) } -model CrownJewel { - id String @id @default(cuid()) - name String @unique // e.g., "Customer Database", "Source Code Repository" - description String - operations Operation[] @relation("OperationCrownJewels") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Target { + id String @id @default(cuid()) + name String @unique // e.g., "Customer Database", "Source Code Repository" + description String + isCrownJewel Boolean @default(false) + operations Operation[] @relation("OperationTargets") + techniqueEngagements TechniqueTarget[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) + @@index([isCrownJewel]) } model Tag { @@ -266,9 +269,9 @@ model Operation { threatActorId String? // Many-to-many relationships - tags Tag[] @relation("OperationTags") - crownJewels CrownJewel[] @relation("OperationCrownJewels") - techniques Technique[] + tags Tag[] @relation("OperationTags") + targets Target[] @relation("OperationTargets") + techniques Technique[] // Access control visibility OperationVisibility @default(EVERYONE) @@ -317,8 +320,6 @@ model Technique { endTime DateTime? sourceIp String? targetSystem String? - crownJewelTargeted Boolean @default(false) - crownJewelCompromised Boolean @default(false) executedSuccessfully Boolean? // Relationships @@ -330,8 +331,9 @@ model Technique { mitreSubTechniqueId String? // Many-to-many relationships - tools Tool[] @relation("TechniqueTools") - outcomes Outcome[] + tools Tool[] @relation("TechniqueTools") + outcomes Outcome[] + targetEngagements TechniqueTarget[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -341,6 +343,20 @@ model Technique { @@index([mitreSubTechniqueId]) } +model TechniqueTarget { + id String @id @default(cuid()) + technique Technique @relation(fields: [techniqueId], references: [id], onDelete: Cascade) + techniqueId String + target Target @relation(fields: [targetId], references: [id], onDelete: Cascade) + targetId String + wasSuccessful Boolean? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([techniqueId, targetId]) + @@index([targetId]) +} + enum OutcomeType { DETECTION PREVENTION diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index bc9431d..760fd0b 100644 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -76,20 +76,24 @@ async function seedTaxonomy() { logSources[name] = ls.id; } - const crownJewels = {} as Record; - for (const cj of [ - "Customer Database", - "Source Code Repository", - "Payment Processing System", - "Email Server", - "HR Records", - ]) { - const c = await db.crownJewel.upsert({ - where: { name: cj }, - update: {}, - create: { name: cj, description: `${cj} crown jewel` }, + const targets = {} as Record; + const targetDefs: Array<{ name: string; description: string; isCrownJewel: boolean }> = [ + { name: "Customer Database", description: "Primary production customer data store", isCrownJewel: true }, + { name: "Source Code Repository", description: "Core application source control", isCrownJewel: true }, + { name: "Payment Processing System", description: "Systems handling card payments", isCrownJewel: true }, + { name: "Email Server", description: "Corporate email infrastructure", isCrownJewel: true }, + { name: "HR Records", description: "Sensitive employee data store", isCrownJewel: true }, + { name: "Employee Credentials", description: "Employee identity and password vault", isCrownJewel: false }, + { name: "Employee Endpoint", description: "Standard employee workstation image", isCrownJewel: false }, + { name: "Application Access Token", description: "Service-to-service OAuth token", isCrownJewel: false }, + ]; + for (const target of targetDefs) { + const record = await db.target.upsert({ + where: { name: target.name }, + update: { description: target.description, isCrownJewel: target.isCrownJewel }, + create: target, }); - crownJewels[cj] = c.id; + targets[target.name] = record.id; } const tags = {} as Record; @@ -170,14 +174,14 @@ async function seedTaxonomy() { threatActors[a.name] = { id: actor.id }; } - return { tools, logSources, crownJewels, threatActors, tags }; + return { tools, logSources, targets, threatActors, tags }; } interface SeedCtx { userId: string; tools: Record; logSources: Record; - crownJewels: Record; + targets: Record; threatActors: Record; tags: Record; } @@ -192,6 +196,11 @@ interface OutcomeSeed { log?: string | null; } +interface TechniqueTargetSeed { + name: string; + status?: "succeeded" | "failed" | "unknown"; +} + interface TechniqueSeed { mitre: string; desc: string; @@ -199,11 +208,10 @@ interface TechniqueSeed { end?: string; source?: string; target?: string; - crown?: boolean; - compromised?: boolean; tools: string[]; outcome?: OutcomeSeed; executedSuccessfully?: boolean; + targets?: TechniqueTargetSeed[]; } interface OperationSeed { @@ -213,13 +221,13 @@ interface OperationSeed { threatActor?: string; start: Date; end: Date | null; - crownJewels: string[]; + targets: string[]; tags: string[]; techniques: TechniqueSeed[]; } async function seedOperations(ctx: SeedCtx) { - const { userId, tools, logSources, crownJewels, threatActors, tags } = ctx; + const { userId, tools, logSources, targets, threatActors, tags } = ctx; const operations: OperationSeed[] = [ { @@ -229,7 +237,7 @@ async function seedOperations(ctx: SeedCtx) { threatActor: threatActors.APT29!.id, start: new Date("2025-01-05T08:00:00Z"), end: new Date("2025-02-15T16:00:00Z"), - crownJewels: ["Email Server", "Payment Processing System"], + targets: ["Email Server", "Payment Processing System", "Employee Credentials"], tags: ["Stealth"], techniques: [ { @@ -325,10 +333,11 @@ async function seedOperations(ctx: SeedCtx) { end: "2025-02-12T15:30:00Z", source: "203.0.113.5", target: "PaymentServer1", - crown: true, - compromised: true, tools: ["Cobalt Strike"], executedSuccessfully: true, + targets: [ + { name: "Payment Processing System", status: "succeeded" }, + ], outcome: { type: OutcomeType.PREVENTION, status: OutcomeStatus.PREVENTED, @@ -348,7 +357,7 @@ async function seedOperations(ctx: SeedCtx) { threatActor: threatActors.FIN7!.id, start: new Date("2025-02-15T10:00:00Z"), end: new Date("2025-05-15T18:00:00Z"), - crownJewels: ["Payment Processing System"], + targets: ["Payment Processing System", "Employee Endpoint"], tags: ["Opportunistic"], techniques: [ { @@ -425,10 +434,11 @@ async function seedOperations(ctx: SeedCtx) { end: "2025-05-07T16:20:00Z", source: "198.51.100.7", target: "PaymentServer1", - crown: true, - compromised: false, tools: ["Nmap"], executedSuccessfully: true, + targets: [ + { name: "Payment Processing System", status: "failed" }, + ], outcome: { type: OutcomeType.DETECTION, status: OutcomeStatus.DETECTED, @@ -467,7 +477,7 @@ async function seedOperations(ctx: SeedCtx) { threatActor: threatActors["Lazarus Group"]!.id, start: new Date("2024-08-01T09:00:00Z"), end: new Date("2025-01-31T17:00:00Z"), - crownJewels: ["Source Code Repository"], + targets: ["Source Code Repository", "Application Access Token"], tags: ["Purple Team"], techniques: [ { @@ -515,10 +525,11 @@ async function seedOperations(ctx: SeedCtx) { end: "2024-11-13T15:10:00Z", source: "203.0.113.9", target: "RepoServer1", - crown: true, - compromised: true, tools: ["Nmap"], executedSuccessfully: true, + targets: [ + { name: "Source Code Repository", status: "succeeded" }, + ], outcome: { type: OutcomeType.DETECTION, status: OutcomeStatus.MISSED, @@ -576,7 +587,7 @@ async function seedOperations(ctx: SeedCtx) { status: OperationStatus.COMPLETED, start: new Date("2025-04-01T08:00:00Z"), end: new Date("2025-07-31T18:00:00Z"), - crownJewels: ["Customer Database", "HR Records"], + targets: ["Customer Database", "HR Records", "Employee Credentials"], tags: ["Stealth"], techniques: [ { @@ -738,10 +749,11 @@ async function seedOperations(ctx: SeedCtx) { end: "2025-07-04T14:10:00Z", source: "192.0.2.10", target: "DBServer1", - crown: true, - compromised: false, tools: ["Nmap"], executedSuccessfully: true, + targets: [ + { name: "Customer Database", status: "failed" }, + ], outcome: { type: OutcomeType.DETECTION, status: OutcomeStatus.DETECTED, @@ -759,10 +771,11 @@ async function seedOperations(ctx: SeedCtx) { end: "2025-07-04T15:30:00Z", source: "192.0.2.10", target: "HRServer", - crown: true, - compromised: true, tools: ["Metasploit"], executedSuccessfully: true, + targets: [ + { name: "Employee Credentials", status: "succeeded" }, + ], outcome: { type: OutcomeType.ATTRIBUTION, status: OutcomeStatus.ATTRIBUTED, @@ -781,7 +794,7 @@ async function seedOperations(ctx: SeedCtx) { status: OperationStatus.PLANNING, start: new Date("2025-06-01T00:00:00Z"), end: null, - crownJewels: ["Source Code Repository"], + targets: ["Source Code Repository", "Employee Credentials"], tags: ["Purple Team"], techniques: [ { @@ -832,8 +845,8 @@ async function seedOperations(ctx: SeedCtx) { endDate: op.end, createdById: userId, threatActorId: op.threatActor ?? undefined, - crownJewels: { - connect: op.crownJewels.map((n) => ({ id: crownJewels[n] })), + targets: { + connect: op.targets.map((n) => ({ id: targets[n] })), }, tags: { connect: op.tags.map((n) => ({ id: tags[n] })), @@ -846,11 +859,20 @@ async function seedOperations(ctx: SeedCtx) { endTime: t.end ? new Date(t.end) : null, sourceIp: t.source, targetSystem: t.target, - crownJewelTargeted: Boolean(t.crown), - crownJewelCompromised: Boolean(t.compromised), mitreTechnique: { connect: { id: t.mitre } }, tools: { connect: t.tools.map((n) => ({ id: tools[n] })) }, executedSuccessfully: t.executedSuccessfully ?? undefined, + targetEngagements: t.targets && t.targets.length > 0 + ? { + create: t.targets.map((targetSeed) => ({ + target: { connect: { id: targets[targetSeed.name] } }, + wasSuccessful: + targetSeed.status === undefined || targetSeed.status === "unknown" + ? null + : targetSeed.status === "succeeded", + })), + } + : undefined, outcomes: t.outcome ? { create: { diff --git a/src/app/(protected-routes)/settings/data/page.tsx b/src/app/(protected-routes)/settings/data/page.tsx index 310d801..8d976bd 100644 --- a/src/app/(protected-routes)/settings/data/page.tsx +++ b/src/app/(protected-routes)/settings/data/page.tsx @@ -142,7 +142,7 @@ export default function DataSettingsPage() { { label: "Techniques", value: stats.techniques }, { label: "Outcomes", value: stats.outcomes }, { label: "Threat Actors", value: stats.threatActors }, - { label: "Crown Jewels", value: stats.crownJewels }, + { label: "Targets", value: stats.targets }, { label: "Tags", value: stats.tags }, { label: "Tools", value: stats.tools }, { label: "Log Sources", value: stats.logSources }, diff --git a/src/app/(protected-routes)/settings/taxonomy/page.tsx b/src/app/(protected-routes)/settings/taxonomy/page.tsx index 72efa74..f5621ed 100644 --- a/src/app/(protected-routes)/settings/taxonomy/page.tsx +++ b/src/app/(protected-routes)/settings/taxonomy/page.tsx @@ -1,5 +1,5 @@ import ThreatActorsTab from "@features/settings/components/threat-actors-tab"; -import CrownJewelsTab from "@features/settings/components/crown-jewels-tab"; +import TargetsTab from "@features/settings/components/targets-tab"; import TagsTab from "@features/settings/components/tags-tab"; import ToolCategoriesTab from "@features/settings/components/tool-categories-tab"; import ToolsTab from "@features/settings/components/tools-tab"; @@ -15,7 +15,7 @@ export default function TaxonomyPage() { - + diff --git a/src/features/analytics/components/scorecard/threat-coverage-section.tsx b/src/features/analytics/components/scorecard/threat-coverage-section.tsx index c2b5949..3ab1ca8 100644 --- a/src/features/analytics/components/scorecard/threat-coverage-section.tsx +++ b/src/features/analytics/components/scorecard/threat-coverage-section.tsx @@ -40,7 +40,11 @@ export function ThreatCoverageSection({ start, end, tagIds }: ThreatCoverageSect } }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const { data: threatActors } = api.taxonomy.threatActors.list.useQuery(); - const { data: crownJewels } = api.taxonomy.crownJewels.list.useQuery(); + const { data: targetsData, isLoading: isTargetsLoading } = api.taxonomy.targets.list.useQuery(); + const crownJewels = useMemo( + () => (targetsData ?? []).filter((target) => target.isCrownJewel), + [targetsData], + ); const operations = useMemo(() => { const pages = operationsPages?.pages ?? []; @@ -130,7 +134,7 @@ export function ThreatCoverageSection({ start, end, tagIds }: ThreatCoverageSect if (!crownJewels) return []; return crownJewels.map((jewel) => { const targetingOps = operations.filter((op) => - op.crownJewels?.some((cj) => cj.id === jewel.id) + op.targets?.some((target) => target.id === jewel.id) ); if (targetingOps.length === 0) { return { @@ -154,7 +158,11 @@ export function ThreatCoverageSection({ start, end, tagIds }: ThreatCoverageSect targetingOps.forEach((op) => { let compromised = false; op.techniques?.forEach((tech) => { - if (tech.crownJewelCompromised) compromised = true; + const jewelEngagements = + tech.targetEngagements?.filter((engagement) => engagement.target?.id === jewel.id) ?? []; + if (jewelEngagements.some((engagement) => engagement.wasSuccessful === true)) { + compromised = true; + } tech.outcomes?.forEach((o) => { if (o.status === "NOT_APPLICABLE") return; if (o.type === "DETECTION") { @@ -200,9 +208,9 @@ export function ThreatCoverageSection({ start, end, tagIds }: ThreatCoverageSect isOperationsLoading || isFetching || isFetchingNextPage || + isTargetsLoading || !operationsPages || - !threatActors || - !crownJewels; + !threatActors; if (isLoading) { return ( diff --git a/src/features/operations/components/create-operation-modal.test.tsx b/src/features/operations/components/create-operation-modal.test.tsx index 388810e..5d74d3e 100644 --- a/src/features/operations/components/create-operation-modal.test.tsx +++ b/src/features/operations/components/create-operation-modal.test.tsx @@ -17,7 +17,7 @@ vi.mock("@/trpc/react", () => { taxonomy: { threatActors: { list: { useQuery: () => ({ data: [] }) } }, tags: { list: { useQuery: () => ({ data: [] }) } }, - crownJewels: { list: { useQuery: () => ({ data: [] }) } }, + targets: { list: { useQuery: () => ({ data: [] }) } }, }, groups: { list: { useQuery: () => ({ data: [] }) } }, operations: { @@ -60,7 +60,7 @@ describe("CreateOperationModal status select", () => { visibility: "EVERYONE" as OperationVisibility, accessGroups: [], tags: [], - crownJewels: [], + targets: [], techniques: [], createdBy: { id: "user-1", name: "Alice", email: "alice@example.com" }, threatActor: null, diff --git a/src/features/operations/components/create-operation-modal.tsx b/src/features/operations/components/create-operation-modal.tsx index 4a73bd1..93e11c6 100644 --- a/src/features/operations/components/create-operation-modal.tsx +++ b/src/features/operations/components/create-operation-modal.tsx @@ -29,7 +29,7 @@ const OperationFormSchema = z startDate: z.string().optional(), endDate: z.string().optional(), tagIds: z.array(z.string()).optional(), - crownJewelIds: z.array(z.string()).optional(), + targetIds: z.array(z.string()).optional(), visibility: z.nativeEnum(OperationVisibility), accessGroupIds: z.array(z.string()).optional(), }) @@ -73,7 +73,7 @@ function buildDefaultValues(operation?: Operation): OperationFormValues { startDate: formatDateForInput(operation?.startDate), endDate: formatDateForInput(operation?.endDate), tagIds: operation?.tags.map((tag) => tag.id) ?? [], - crownJewelIds: operation?.crownJewels.map((cj) => cj.id) ?? [], + targetIds: operation?.targets.map((target) => target.id) ?? [], visibility: operation?.visibility ?? OperationVisibility.EVERYONE, accessGroupIds: operation?.accessGroups?.map(({ group }) => group.id) ?? [], }; @@ -108,7 +108,7 @@ export default function CreateOperationModal({ isOpen, onClose, onSuccess, opera const visibility = watch("visibility"); const selectedTagIds = watch("tagIds") ?? []; - const selectedCrownJewelIds = watch("crownJewelIds") ?? []; + const selectedTargetIds = watch("targetIds") ?? []; const selectedGroupIds = watch("accessGroupIds") ?? []; useEffect(() => { @@ -119,12 +119,12 @@ export default function CreateOperationModal({ isOpen, onClose, onSuccess, opera const { data: threatActorsData } = api.taxonomy.threatActors.list.useQuery(); const { data: tagsData } = api.taxonomy.tags.list.useQuery(); - const { data: crownJewelsData } = api.taxonomy.crownJewels.list.useQuery(); + const { data: targetsData } = api.taxonomy.targets.list.useQuery(); const { data: groupsData } = api.groups.list.useQuery(); const threatActors = threatActorsData ?? []; const tags = tagsData ?? []; - const crownJewels = crownJewelsData ?? []; + const targets = targetsData ?? []; const groups = groupsData ?? []; const utils = api.useUtils(); @@ -168,7 +168,7 @@ export default function CreateOperationModal({ isOpen, onClose, onSuccess, opera startDate: values.startDate ? new Date(values.startDate) : undefined, endDate: values.endDate ? new Date(values.endDate) : undefined, tagIds: values.tagIds ?? [], - crownJewelIds: values.crownJewelIds ?? [], + targetIds: values.targetIds ?? [], visibility: values.visibility, accessGroupIds: values.visibility === OperationVisibility.GROUPS_ONLY ? values.accessGroupIds ?? [] : [], }; @@ -373,12 +373,12 @@ export default function CreateOperationModal({ isOpen, onClose, onSuccess, opera {/* Crown Jewels Selection */} setValue("crownJewelIds", ids, { shouldDirty: true })} - label="Crown Jewels Targeting" - description="Select crown jewels this operation will target:" + variant="targets" + items={targets} + selectedIds={selectedTargetIds} + onSelectionChange={(ids) => setValue("targetIds", ids, { shouldDirty: true })} + label="Planned Targets" + description="Select assets this operation intends to target. Crown jewels are flagged with a CJ badge." searchable multiple /> diff --git a/src/features/operations/components/operation-detail-page/index.tsx b/src/features/operations/components/operation-detail-page/index.tsx index 755c10d..b19eb34 100644 --- a/src/features/operations/components/operation-detail-page/index.tsx +++ b/src/features/operations/components/operation-detail-page/index.tsx @@ -147,10 +147,11 @@ export default function OperationDetailPage({ operationId }: Props) { Targeting: - {operation.crownJewels.length > 0 - ? operation.crownJewels.map(cj => cj.name).join(', ') - : 'General Infrastructure' - } + {operation.targets.length > 0 + ? operation.targets + .map((target) => (target.isCrownJewel ? `${target.name} (CJ)` : target.name)) + .join(', ') + : 'General Infrastructure'} diff --git a/src/features/operations/components/operations-page.tsx b/src/features/operations/components/operations-page.tsx index 4aacae2..e78b915 100644 --- a/src/features/operations/components/operations-page.tsx +++ b/src/features/operations/components/operations-page.tsx @@ -223,11 +223,14 @@ export default function OperationsPage() { )} - {operation.crownJewels.length > 0 && ( + {operation.targets.length > 0 && (

- Targeting: {operation.crownJewels.map(jewel => jewel.name).join(", ")} + Targeting:{" "} + {operation.targets + .map((target) => (target.isCrownJewel ? `${target.name} (CJ)` : target.name)) + .join(", ")}
)} diff --git a/src/features/operations/components/technique-editor/execution-section.tsx b/src/features/operations/components/technique-editor/execution-section.tsx index 8c70e5a..cef9366 100644 --- a/src/features/operations/components/technique-editor/execution-section.tsx +++ b/src/features/operations/components/technique-editor/execution-section.tsx @@ -27,12 +27,11 @@ export interface ExecutionSectionProps { selectedOffensiveToolIds: string[]; onOffensiveToolIdsChange: (ids: string[]) => void; - // Crown jewels - crownJewels: Array<{ id: string; name: string; description: string }> | undefined; - selectedCrownJewelIds: string[]; - onCrownJewelIdsChange: (ids: string[]) => void; - crownJewelAccess: string; // "yes" | "no" | "" - onCrownJewelAccessChange: (value: string) => void; + // Targets + targets: Array<{ id: string; name: string; description: string; isCrownJewel?: boolean }> | undefined; + selectedTargets: Array<{ id: string; name: string; isCrownJewel?: boolean; status: "unknown" | "succeeded" | "failed" }>; + onTargetIdsChange: (ids: string[]) => void; + onTargetStatusChange: (targetId: string, status: "unknown" | "succeeded" | "failed") => void; executionSuccess: string; // "yes" | "no" | "" onExecutionSuccessChange: (value: string) => void; } @@ -50,11 +49,10 @@ export default function ExecutionSection(props: ExecutionSectionProps) { offensiveTools, selectedOffensiveToolIds, onOffensiveToolIdsChange, - crownJewels, - selectedCrownJewelIds, - onCrownJewelIdsChange, - crownJewelAccess, - onCrownJewelAccessChange, + targets, + selectedTargets, + onTargetIdsChange, + onTargetStatusChange, executionSuccess, onExecutionSuccessChange, } = props; @@ -135,32 +133,60 @@ export default function ExecutionSection(props: ExecutionSectionProps) {
({ ...target }))} + selectedIds={selectedTargets.map((target) => target.id)} + onSelectionChange={onTargetIdsChange} + label="Targets" + description="Select assets this technique targeted. Crown jewels are flagged with a CJ badge." compactHeader searchable={false} multiple={true} /> - {selectedCrownJewelIds.length > 0 && ( -
- -
- {(["yes", "no"] as const).map(opt => { - const selected = crownJewelAccess === opt; + {selectedTargets.length > 0 && ( +
+ +
+ {selectedTargets.map((target) => { + const statusOptions: Array<{ + value: "succeeded" | "failed" | "unknown"; + label: string; + }> = [ + { value: "succeeded", label: "Compromised" }, + { value: "failed", label: "Not Compromised" }, + { value: "unknown", label: "Not Recorded" }, + ]; + return ( - onCrownJewelAccessChange(selected ? "" : opt)} +
- {opt === "yes" ? "Yes" : "No"} - +
+ {target.name} + {target.isCrownJewel && ( + + CJ + + )} +
+
+ {statusOptions.map((option) => { + const selected = target.status === option.value; + return ( + onTargetStatusChange(target.id, selected ? "unknown" : option.value)} + > + {option.label} + + ); + })} +
+
); })}
diff --git a/src/features/operations/components/technique-editor/index.tsx b/src/features/operations/components/technique-editor/index.tsx index cb09487..0ce4337 100644 --- a/src/features/operations/components/technique-editor/index.tsx +++ b/src/features/operations/components/technique-editor/index.tsx @@ -7,16 +7,20 @@ // PR2 move: features/operations/components import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { Button, Card, CardHeader, CardTitle, Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; import { X, FileText, Play, Target, Edit } from "lucide-react"; import { getCuratedShortDescription } from "@/lib/mitreDescriptionUtils"; import { useTechniqueEditorData, useTechniqueEditorForm } from "@/features/operations/hooks"; +import type { TechniqueEditorFormValues } from "@/features/operations/hooks/useTechniqueEditorForm"; import type { SelectedTechnique } from "@/features/operations/techniqueEditor.types"; import OverviewSection from "./overview-section"; import ExecutionSection from "./execution-section"; import OutcomesSection from "./outcomes-section"; +type Operation = RouterOutputs["operations"]["getById"]; +type Technique = Operation["techniques"][number]; + // type Technique = RouterOutputs["techniques"]["getById"]; // Unused // SelectedTechnique moved to shared types for reuse across sections @@ -52,9 +56,10 @@ export default function TechniqueEditorModal({ ); // Get existing technique data from operation (if in edit mode) - const existingTechnique = isEditMode && techniqueId - ? operation?.techniques?.find(t => t.id === techniqueId) - : undefined; + const existingTechnique: Technique | undefined = + isEditMode && techniqueId + ? operation?.techniques?.find((technique) => technique.id === techniqueId) + : undefined; // Centralized data for editor const editorData = useTechniqueEditorData({ isOpen, selectedTacticId }); @@ -96,7 +101,6 @@ export default function TechniqueEditorModal({ const formHook = useTechniqueEditorForm({ operationId, existingTechnique: isEditMode ? existingTechnique : undefined, - crownJewels: operation?.crownJewels, onSuccess, onClose, }); @@ -284,12 +288,72 @@ export default function TechniqueEditorModal({ const startTimeValue = form.watch("startTime") ?? ""; const hasExecutionStart = Boolean(startTimeValue.trim()); - const cjRaw = form.watch("crownJewelAccess") ?? ""; - const cjAccess: "" | "yes" | "no" = cjRaw === "yes" ? "yes" : cjRaw === "no" ? "no" : ""; + const watchedTargetEngagements = form.watch("targetEngagements"); + const targetEngagements: TechniqueEditorFormValues["targetEngagements"] = useMemo( + () => watchedTargetEngagements ?? [], + [watchedTargetEngagements], + ); const execRaw = form.watch("executionSuccess") ?? ""; const execSuccess: "" | "yes" | "no" = execRaw === "yes" ? "yes" : execRaw === "no" ? "no" : ""; const isFormValid = Boolean(selectedTechnique) && form.formState.isValid; + const targetLookup = useMemo(() => { + const map = new Map(); + (operation?.targets ?? []).forEach((target) => { + map.set(target.id, { + id: target.id, + name: target.name, + isCrownJewel: target.isCrownJewel, + }); + }); + + (existingTechnique?.targetEngagements ?? []).forEach((engagement) => { + if (map.has(engagement.targetId)) return; + if (engagement.target) { + map.set(engagement.targetId, { + id: engagement.target.id, + name: engagement.target.name, + isCrownJewel: engagement.target.isCrownJewel, + }); + } + }); + + return map; + }, [operation?.targets, existingTechnique?.targetEngagements]); + + const selectedTargets = useMemo( + () => + targetEngagements.map((engagement) => { + const details = targetLookup.get(engagement.targetId); + return { + id: engagement.targetId, + name: details?.name ?? "Unknown Target", + isCrownJewel: details?.isCrownJewel ?? false, + status: engagement.status, + }; + }), + [targetEngagements, targetLookup], + ); + + const handleTargetIdsChange = useCallback( + (ids: string[]) => { + const current = new Map(targetEngagements.map((engagement) => [engagement.targetId, engagement.status] as const)); + const next = ids.map((id) => ({ targetId: id, status: current.get(id) ?? "unknown" })); + form.setValue("targetEngagements", next, { shouldDirty: true }); + }, + [form, targetEngagements], + ); + + const handleTargetStatusChange = useCallback( + (targetId: string, status: "unknown" | "succeeded" | "failed") => { + const next = targetEngagements.map((engagement) => + engagement.targetId === targetId ? { ...engagement, status } : engagement, + ); + form.setValue("targetEngagements", next, { shouldDirty: true }); + }, + [form, targetEngagements], + ); + useEffect(() => { if (hasExecutionStart) return; @@ -385,11 +449,15 @@ export default function TechniqueEditorModal({ offensiveTools={offensiveTools.map(t => ({ id: t.id, name: t.name }))} selectedOffensiveToolIds={form.watch("offensiveToolIds")} onOffensiveToolIdsChange={(ids) => form.setValue("offensiveToolIds", ids, { shouldDirty: true })} - crownJewels={operation?.crownJewels?.map((cj) => ({ id: cj.id, name: cj.name, description: cj.description ?? "" }))} - selectedCrownJewelIds={form.watch("selectedCrownJewelIds")} - onCrownJewelIdsChange={(ids) => form.setValue("selectedCrownJewelIds", ids, { shouldDirty: true })} - crownJewelAccess={cjAccess} - onCrownJewelAccessChange={(value) => form.setValue("crownJewelAccess", value as "" | "yes" | "no", { shouldDirty: true })} + targets={operation?.targets?.map((target) => ({ + id: target.id, + name: target.name, + description: target.description ?? "", + isCrownJewel: target.isCrownJewel, + }))} + selectedTargets={selectedTargets} + onTargetIdsChange={handleTargetIdsChange} + onTargetStatusChange={handleTargetStatusChange} executionSuccess={execSuccess} onExecutionSuccessChange={(value) => form.setValue("executionSuccess", value as "" | "yes" | "no", { shouldDirty: true })} /> diff --git a/src/features/operations/components/technique-editor/taxonomy-selector.tsx b/src/features/operations/components/technique-editor/taxonomy-selector.tsx index f589d76..13e20a7 100644 --- a/src/features/operations/components/technique-editor/taxonomy-selector.tsx +++ b/src/features/operations/components/technique-editor/taxonomy-selector.tsx @@ -24,8 +24,9 @@ interface TagItem extends TaxonomyItem { color: string; } -interface CrownJewelItem extends TaxonomyItem { +interface TargetItem extends TaxonomyItem { description: string; + isCrownJewel?: boolean; } interface ThreatActorItem extends TaxonomyItem { @@ -59,8 +60,8 @@ interface TagSelectorProps extends BaseSelectorProps { variant: "tags"; } -interface CrownJewelSelectorProps extends BaseSelectorProps { - variant: "crown-jewels"; +interface TargetSelectorProps extends BaseSelectorProps { + variant: "targets"; } interface ThreatActorSelectorProps extends BaseSelectorProps { @@ -68,7 +69,7 @@ interface ThreatActorSelectorProps extends BaseSelectorProps { multiple?: false; // Threat actor is typically single selection } -type TaxonomySelectorProps = TagSelectorProps | CrownJewelSelectorProps | ThreatActorSelectorProps; +type TaxonomySelectorProps = TagSelectorProps | TargetSelectorProps | ThreatActorSelectorProps; interface ToolsSelectorProps extends BaseSelectorProps { variant: "tools"; } @@ -85,11 +86,11 @@ const selectorConfig = { emptyMessage: "No tags available", searchPlaceholder: "Search tags...", }, - "crown-jewels": { + targets: { icon: Shield, - title: "Crown Jewels", - emptyMessage: "No crown jewels available", - searchPlaceholder: "Search crown jewels...", + title: "Targets", + emptyMessage: "No targets available", + searchPlaceholder: "Search targets...", }, "threat-actors": { icon: Target, @@ -222,7 +223,7 @@ export default function TaxonomySelector(props: AllSelectorProps) { ); } - // Render for multiple selection (tags, crown jewels) + // Render for multiple selection (tags, targets) return (
{compactHeader ? ( @@ -302,9 +303,10 @@ export default function TaxonomySelector(props: AllSelectorProps) {
)} - {variant === "crown-jewels" && ( + {variant === "targets" && (
{filteredItems.map((item) => { + const targetItem = item as TargetItem; const isSelected = selectedIds.includes(item.id); return ( toggleSelection(item.id)} > - {item.name} + + {item.name} + {targetItem.isCrownJewel && ( + CJ + )} + ); })} diff --git a/src/features/operations/components/technique-list/sortable-technique-card.tsx b/src/features/operations/components/technique-list/sortable-technique-card.tsx index 5b6c441..8177e0a 100644 --- a/src/features/operations/components/technique-list/sortable-technique-card.tsx +++ b/src/features/operations/components/technique-list/sortable-technique-card.tsx @@ -64,6 +64,13 @@ export default function SortableTechniqueCard({ technique, onEdit, canEdit }: So const detectionStatus = getStatusLabel(detectionOutcome, "DETECTION"); const preventionStatus = getStatusLabel(preventionOutcome, "PREVENTION"); const attributionStatus = getStatusLabel(attributionOutcome, "ATTRIBUTION"); + const targetEngagements = technique.targetEngagements ?? []; + + const targetBadgeVariant = (success: boolean | null | undefined) => { + if (success === true) return "error" as const; + if (success === false) return "success" as const; + return "outline" as const; + }; return (
@@ -135,6 +142,33 @@ export default function SortableTechniqueCard({ technique, onEdit, canEdit }: So
+ {targetEngagements.length > 0 && ( +
+ {targetEngagements.map((engagement) => { + const targetName = engagement.target?.name ?? "Unknown Target"; + const statusLabel = + engagement.wasSuccessful === true + ? "Compromised" + : engagement.wasSuccessful === false + ? "Not Compromised" + : "Not Recorded"; + return ( + + {engagement.target?.isCrownJewel && ( + CJ + )} + {targetName} + {statusLabel} + + ); + })} +
+ )} +
{detectionStatus !== "N/A" && (
@@ -181,13 +215,7 @@ export default function SortableTechniqueCard({ technique, onEdit, canEdit }: So
)} -
- {technique.crownJewelTargeted && ( - - {technique.crownJewelCompromised ? "Crown Jewel Compromised" : "Crown Jewel Targeted"} - - )} -
+
diff --git a/src/features/operations/hooks/useTechniqueEditorForm.ts b/src/features/operations/hooks/useTechniqueEditorForm.ts index 0a77784..0bbb515 100644 --- a/src/features/operations/hooks/useTechniqueEditorForm.ts +++ b/src/features/operations/hooks/useTechniqueEditorForm.ts @@ -15,6 +15,11 @@ import { logger } from "@/lib/logger"; import type { SelectedTechnique } from "@/features/operations/techniqueEditor.types"; const OutcomeStateSchema = z.union([z.literal("yes"), z.literal("no"), z.literal("N/A")]); +const TargetEngagementStatusSchema = z.union([ + z.literal("unknown"), + z.literal("succeeded"), + z.literal("failed"), +]); export const TechniqueEditorFormSchema = z.object({ description: z.string().default(""), @@ -23,8 +28,14 @@ export const TechniqueEditorFormSchema = z.object({ sourceIp: z.string().optional(), targetSystems: z.string().optional(), offensiveToolIds: z.array(z.string()).default([]), - selectedCrownJewelIds: z.array(z.string()).default([]), - crownJewelAccess: z.union([z.literal("yes"), z.literal("no"), z.literal("")]).default(""), + targetEngagements: z + .array( + z.object({ + targetId: z.string(), + status: TargetEngagementStatusSchema, + }), + ) + .default([]), executionSuccess: z.union([z.literal("yes"), z.literal("no"), z.literal("")]).default(""), outcomes: z.object({ detection: z.object({ state: OutcomeStateSchema.default("N/A"), time: z.string().optional(), toolIds: z.array(z.string()).default([]) }), @@ -78,11 +89,10 @@ export type TechniqueEditorFormValues = z.infer void; onClose: () => void; }) { - const { operationId, existingTechnique, crownJewels, onSuccess, onClose } = params; + const { operationId, existingTechnique, onSuccess, onClose } = params; const defaultValues = useMemo(() => { const fmt = (d?: Date | string | null) => { @@ -104,8 +114,7 @@ export function useTechniqueEditorForm(params: { sourceIp: "", targetSystems: "", offensiveToolIds: [], - selectedCrownJewelIds: [], - crownJewelAccess: "", + targetEngagements: [], executionSuccess: "", outcomes: { detection: { state: "N/A", time: "", toolIds: [] }, @@ -119,7 +128,11 @@ export function useTechniqueEditorForm(params: { const prevention = existingTechnique.outcomes?.find((o) => o.type === "PREVENTION"); const attribution = existingTechnique.outcomes?.find((o) => o.type === "ATTRIBUTION"); - const selectedCJ = existingTechnique.crownJewelTargeted ? (crownJewels ?? []).map((cj) => cj.id) : []; + const engagementStatus = (value: boolean | null | undefined): "unknown" | "succeeded" | "failed" => { + if (value === true) return "succeeded"; + if (value === false) return "failed"; + return "unknown"; + }; return { description: existingTechnique.description ?? "", @@ -128,8 +141,11 @@ export function useTechniqueEditorForm(params: { sourceIp: existingTechnique.sourceIp ?? "", targetSystems: existingTechnique.targetSystem ?? "", offensiveToolIds: existingTechnique.tools?.map((t) => t.id) ?? [], - selectedCrownJewelIds: selectedCJ, - crownJewelAccess: existingTechnique.crownJewelCompromised ? "yes" : existingTechnique.crownJewelTargeted ? "no" : "", + targetEngagements: + existingTechnique.targetEngagements?.map((engagement) => ({ + targetId: engagement.targetId, + status: engagementStatus(engagement.wasSuccessful), + })) ?? [], executionSuccess: existingTechnique.executedSuccessfully == null ? "" : existingTechnique.executedSuccessfully ? "yes" : "no", outcomes: { detection: { @@ -148,7 +164,7 @@ export function useTechniqueEditorForm(params: { }, }, }; - }, [existingTechnique, crownJewels]); + }, [existingTechnique]); const form = useForm({ defaultValues, @@ -212,16 +228,19 @@ export function useTechniqueEditorForm(params: { if (!selected) return; const startTimeValue = values.startTime?.trim() ? new Date(values.startTime) : undefined; const endTimeValue = values.endTime?.trim() ? new Date(values.endTime) : undefined; + const targetEngagementsPayload = values.targetEngagements.map((engagement) => ({ + targetId: engagement.targetId, + status: engagement.status, + })); const base = { mitreTechniqueId: selected.technique.id, mitreSubTechniqueId: selected.subTechnique?.id, description: values.description, sourceIp: values.sourceIp ?? undefined, targetSystem: values.targetSystems ?? undefined, - crownJewelTargeted: values.selectedCrownJewelIds.length > 0, - crownJewelCompromised: values.crownJewelAccess === "yes", toolIds: values.offensiveToolIds, executedSuccessfully: values.executionSuccess === "" ? undefined : values.executionSuccess === "yes", + targetEngagements: targetEngagementsPayload, } as const; try { diff --git a/src/features/settings/components/crown-jewels-tab.tsx b/src/features/settings/components/crown-jewels-tab.tsx deleted file mode 100644 index 8ab1167..0000000 --- a/src/features/settings/components/crown-jewels-tab.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { api } from "@/trpc/react"; -import { Button, Input, Label } from "@components/ui"; -import SettingsHeader from "./settings-header"; -import InlineActions from "@components/ui/inline-actions"; -import { type CrownJewel } from "@prisma/client"; -import EntityListCard from "./entity-list-card"; -import EntityModal from "@components/ui/entity-modal"; -import ConfirmModal from "@components/ui/confirm-modal"; - -export default function CrownJewelsTab() { - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [editingJewel, setEditingJewel] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(null); - - // Queries - const { data: crownJewels, isLoading } = api.taxonomy.crownJewels.list.useQuery(); - - // Mutations - const utils = api.useUtils(); - const createMutation = api.taxonomy.crownJewels.create.useMutation({ - onSuccess: () => { - void utils.taxonomy.crownJewels.invalidate(); - setIsCreateModalOpen(false); - }, - }); - - const updateMutation = api.taxonomy.crownJewels.update.useMutation({ - onSuccess: () => { - void utils.taxonomy.crownJewels.invalidate(); - setEditingJewel(null); - }, - }); - - const deleteMutation = api.taxonomy.crownJewels.delete.useMutation({ - onSuccess: () => { - void utils.taxonomy.crownJewels.invalidate(); - setConfirmDelete(null); - }, - }); - - const handleCreate = (data: { name: string; description: string }) => { - createMutation.mutate(data); - }; - - const handleUpdate = (id: string, data: { name?: string; description?: string }) => { - updateMutation.mutate({ id, ...data }); - }; - - const handleDelete = (id: string) => { - deleteMutation.mutate({ id }); - }; - - - if (isLoading) { - return ( -
-
Loading crown jewels...
-
- ); - } - - const jewels = (crownJewels ?? []) as Array; - - return ( -
- setIsCreateModalOpen(true)} /> - -
- {jewels.map((jewel) => ( - {jewel.name}} - description={jewel.description} - actions={ setEditingJewel(jewel)} onDelete={() => setConfirmDelete(jewel)} deleteDisabled={jewel.usageCount > 0} deleteDisabledReason={jewel.usageCount > 0 ? `In use by ${jewel.usageCount} operation(s)` : undefined} />} - /> - ))} - - {(!crownJewels || crownJewels.length === 0) && ( -
- No crown jewels configured. Use + New to create one. -
- )} -
- - {isCreateModalOpen && ( - setIsCreateModalOpen(false)} - isLoading={createMutation.isPending} - /> - )} - - {editingJewel && ( - handleUpdate(editingJewel.id, data)} - onClose={() => setEditingJewel(null)} - isLoading={updateMutation.isPending} - /> - )} - - {/* Delete confirmation */} - {confirmDelete && ( - handleDelete(confirmDelete.id)} - onCancel={() => setConfirmDelete(null)} - loading={deleteMutation.isPending} - /> - )} -
- ); -} - -interface CrownJewelEntityModalProps { - title: string; - initialData?: CrownJewel; - onSubmit: (data: { name: string; description: string }) => void; - onClose: () => void; - isLoading: boolean; -} - -function CrownJewelEntityModal({ title, initialData, onSubmit, onClose, isLoading }: CrownJewelEntityModalProps) { - const [name, setName] = useState(initialData?.name ?? ""); - const [description, setDescription] = useState(initialData?.description ?? ""); - - const handleSubmit = () => { - onSubmit({ name, description }); - }; - - return ( - - - - - )} - maxWidthClass="max-w-md" - > -
-
- - setName(e.target.value)} - placeholder="e.g., Customer Database, Payment System" - required - /> -
- -
- -