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 && (
-
-
Successfully Accessed Crown Jewels
-
- {(["yes", "no"] as const).map(opt => {
- const selected = crownJewelAccess === opt;
+ {selectedTargets.length > 0 && (
+
+
Target outcomes
+
+ {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 (
-
- Cancel
-
- {isLoading ? "Saving..." : "Save"}
-
- >
- )}
- maxWidthClass="max-w-md"
- >
-
-
-
- Asset Name
-
- setName(e.target.value)}
- placeholder="e.g., Customer Database, Payment System"
- required
- />
-
-
-
-
- Description
-
-
-
-
-
-
- );
-}
diff --git a/src/features/settings/components/targets-tab.tsx b/src/features/settings/components/targets-tab.tsx
new file mode 100644
index 0000000..aed7da4
--- /dev/null
+++ b/src/features/settings/components/targets-tab.tsx
@@ -0,0 +1,217 @@
+"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 Target } 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 TargetsTab() {
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [editingTarget, setEditingTarget] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(null);
+
+ const { data: targets, isLoading } = api.taxonomy.targets.list.useQuery();
+
+ const utils = api.useUtils();
+ const createMutation = api.taxonomy.targets.create.useMutation({
+ onSuccess: () => {
+ void utils.taxonomy.targets.invalidate();
+ setIsCreateModalOpen(false);
+ },
+ });
+
+ const updateMutation = api.taxonomy.targets.update.useMutation({
+ onSuccess: () => {
+ void utils.taxonomy.targets.invalidate();
+ setEditingTarget(null);
+ },
+ });
+
+ const deleteMutation = api.taxonomy.targets.delete.useMutation({
+ onSuccess: () => {
+ void utils.taxonomy.targets.invalidate();
+ setConfirmDelete(null);
+ },
+ });
+
+ const handleCreate = (data: { name: string; description: string; isCrownJewel: boolean }) => {
+ createMutation.mutate(data);
+ };
+
+ const handleUpdate = (id: string, data: { name?: string; description?: string; isCrownJewel?: boolean }) => {
+ updateMutation.mutate({ id, ...data });
+ };
+
+ const handleDelete = (id: string) => {
+ deleteMutation.mutate({ id });
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ const items = (targets ?? []) as Array;
+
+ return (
+
+
setIsCreateModalOpen(true)}
+ />
+
+
+ {items.map((target) => (
+
+ {target.name}
+ {target.isCrownJewel && (Crown Jewel) }
+
+ }
+ description={target.description}
+ actions={
+ setEditingTarget(target)}
+ onDelete={() => setConfirmDelete(target)}
+ deleteDisabled={target.usageCount > 0}
+ deleteDisabledReason={target.usageCount > 0 ? `In use by ${target.usageCount} operation(s)` : undefined}
+ />
+ }
+ />
+ ))}
+
+ {items.length === 0 && (
+
+ No targets configured. Use + New to create one.
+
+ )}
+
+
+ {isCreateModalOpen && (
+ setIsCreateModalOpen(false)}
+ isLoading={createMutation.isPending}
+ />
+ )}
+
+ {editingTarget && (
+ handleUpdate(editingTarget.id, data)}
+ onClose={() => setEditingTarget(null)}
+ isLoading={updateMutation.isPending}
+ />
+ )}
+
+ {confirmDelete && (
+ handleDelete(confirmDelete.id)}
+ onCancel={() => setConfirmDelete(null)}
+ loading={deleteMutation.isPending}
+ />
+ )}
+
+ );
+}
+
+interface TargetModalProps {
+ title: string;
+ initialData?: Target;
+ onSubmit: (data: { name: string; description: string; isCrownJewel: boolean }) => void;
+ onClose: () => void;
+ isLoading: boolean;
+}
+
+function TargetModal({ title, initialData, onSubmit, onClose, isLoading }: TargetModalProps) {
+ const [name, setName] = useState(initialData?.name ?? "");
+ const [description, setDescription] = useState(initialData?.description ?? "");
+ const [isCrownJewel, setIsCrownJewel] = useState(initialData?.isCrownJewel ?? false);
+
+ const handleSubmit = () => {
+ onSubmit({ name, description, isCrownJewel });
+ };
+
+ return (
+
+
+ Cancel
+
+
+ {isLoading ? "Saving..." : "Save"}
+
+ >
+ )}
+ maxWidthClass="max-w-md"
+ >
+
+
+
+ Asset Name
+
+ setName(e.target.value)}
+ placeholder="e.g., Customer Database, Payment System"
+ required
+ />
+
+
+
+
+ Description
+
+
+
+
+ setIsCrownJewel(event.target.checked)}
+ />
+ Mark as Crown Jewel
+
+
+
+ );
+}
diff --git a/src/lib/exportOperationReport.ts b/src/lib/exportOperationReport.ts
index a5a4048..1065f48 100644
--- a/src/lib/exportOperationReport.ts
+++ b/src/lib/exportOperationReport.ts
@@ -82,7 +82,13 @@ function buildMarkdown(operation: Operation) {
"## Operation Overview",
"",
`- Threat Actor: ${operation.threatActor?.name ?? "N/A"}`,
- `- Crown Jewels: ${operation.crownJewels.map(c => c.name).join(', ') || "N/A"}`,
+ `- Targets: ${
+ operation.targets.length
+ ? operation.targets
+ .map((target) => (target.isCrownJewel ? `${target.name} (CJ)` : target.name))
+ .join(', ')
+ : "N/A"
+ }`,
`- Detection Success Rate: ${detectionStats.successRate}% (${detectionStats.successes}/${detectionStats.attempts})`,
`- Prevention Success Rate: ${preventionStats.successRate}% (${preventionStats.successes}/${preventionStats.attempts})`,
`- Attribution Success Rate: ${attributionStats.successRate}% (${attributionStats.successes}/${attributionStats.attempts})`,
diff --git a/src/server/api/routers/analytics/scorecard.ts b/src/server/api/routers/analytics/scorecard.ts
index c1eefa8..8050296 100644
--- a/src/server/api/routers/analytics/scorecard.ts
+++ b/src/server/api/routers/analytics/scorecard.ts
@@ -40,8 +40,6 @@ export const scorecardRouter = createTRPCRouter({
id: true,
startTime: true,
executedSuccessfully: true,
- crownJewelTargeted: true,
- crownJewelCompromised: true,
operationId: true,
operation: { select: { id: true, name: true, threatActorId: true } },
mitreTechnique: { include: { tactic: true } },
@@ -49,6 +47,11 @@ export const scorecardRouter = createTRPCRouter({
where: { status: { not: OutcomeStatus.NOT_APPLICABLE } },
select: { type: true, status: true, detectionTime: true },
},
+ targetEngagements: {
+ include: {
+ target: true,
+ },
+ },
},
}),
ctx.db.tool.findMany({
@@ -200,13 +203,16 @@ export const scorecardRouter = createTRPCRouter({
}
}
- if (technique.crownJewelTargeted) {
- cjAttempts++;
- if (technique.crownJewelCompromised) {
- cjSuccesses++;
- }
- crownJewelOperations.add(technique.operationId);
+ const crownJewelEngagements = technique.targetEngagements?.filter(
+ (engagement) => engagement.target?.isCrownJewel,
+ );
+ if (crownJewelEngagements && crownJewelEngagements.length > 0) {
+ cjAttempts++;
+ if (crownJewelEngagements.some((engagement) => engagement.wasSuccessful === true)) {
+ cjSuccesses++;
}
+ crownJewelOperations.add(technique.operationId);
+ }
}
technique.outcomes.forEach((outcome) => {
diff --git a/src/server/api/routers/analytics/summary.ts b/src/server/api/routers/analytics/summary.ts
index 9e03e61..6ad4f00 100644
--- a/src/server/api/routers/analytics/summary.ts
+++ b/src/server/api/routers/analytics/summary.ts
@@ -343,9 +343,12 @@ export const summaryRouter = createTRPCRouter({
},
},
select: {
- crownJewelTargeted: true,
- crownJewelCompromised: true,
operation: { select: { id: true, createdAt: true, startDate: true } },
+ targetEngagements: {
+ include: {
+ target: true,
+ },
+ },
},
});
@@ -367,10 +370,11 @@ export const summaryRouter = createTRPCRouter({
const key = makeKey(baseDate);
if (!groups.has(key)) groups.set(key, { date: key, attempts: 0, successes: 0, targetedOps: new Set() });
const g = groups.get(key)!;
- if (t.crownJewelTargeted) {
+ const crownTargets = t.targetEngagements?.filter((engagement) => engagement.target?.isCrownJewel) ?? [];
+ if (crownTargets.length > 0) {
g.attempts++;
g.targetedOps.add(t.operation.id);
- if (t.crownJewelCompromised) g.successes++;
+ if (crownTargets.some((engagement) => engagement.wasSuccessful === true)) g.successes++;
}
});
diff --git a/src/server/api/routers/data/index.ts b/src/server/api/routers/data/index.ts
index d0e53fe..0decf58 100644
--- a/src/server/api/routers/data/index.ts
+++ b/src/server/api/routers/data/index.ts
@@ -7,6 +7,7 @@ import { logger } from "@/server/logger";
const clearUserData = async (tx: Prisma.TransactionClient) => {
await tx.outcome.deleteMany();
+ await tx.techniqueTarget.deleteMany();
await tx.technique.deleteMany();
await tx.attackFlowLayout.deleteMany();
await tx.operation.deleteMany();
@@ -15,7 +16,7 @@ const clearUserData = async (tx: Prisma.TransactionClient) => {
await tx.toolCategory.deleteMany();
await tx.logSource.deleteMany();
await tx.tag.deleteMany();
- await tx.crownJewel.deleteMany();
+ await tx.target.deleteMany();
await tx.threatActor.deleteMany();
};
@@ -26,10 +27,11 @@ const threatActorSchema = z.object({
topThreat: z.boolean().optional(),
});
-const crownJewelSchema = z.object({
+const targetSchema = z.object({
id: z.string().optional(),
name: z.string(),
description: z.string(),
+ isCrownJewel: z.boolean().optional(),
});
const tagSchema = z.object({
@@ -68,7 +70,7 @@ const operationSchema = z.object({
createdById: z.string(),
threatActorId: z.string().optional().nullable(),
tags: z.array(z.object({ id: z.string() })).optional(),
- crownJewels: z.array(z.object({ id: z.string() })).optional(),
+ targets: z.array(z.object({ id: z.string() })).optional(),
visibility: z.enum(["EVERYONE", "GROUPS_ONLY"]).optional(),
});
@@ -80,13 +82,19 @@ const techniqueSchema = z.object({
endTime: z.coerce.date().optional().nullable(),
sourceIp: z.string().optional().nullable(),
targetSystem: z.string().optional().nullable(),
- crownJewelTargeted: z.boolean().optional(),
- crownJewelCompromised: z.boolean().optional(),
executedSuccessfully: z.boolean().optional().nullable(),
operationId: z.number(),
mitreTechniqueId: z.string().optional().nullable(),
mitreSubTechniqueId: z.string().optional().nullable(),
tools: z.array(z.object({ id: z.string() })).optional(),
+ targetEngagements: z
+ .array(
+ z.object({
+ targetId: z.string(),
+ status: z.enum(["unknown", "succeeded", "failed"]).optional(),
+ }),
+ )
+ .optional(),
});
const outcomeSchema = z.object({
@@ -116,7 +124,7 @@ const threatActorTechniqueLinkSchema = z.object({
const backupPayloadSchema = z.object({
threatActors: z.array(threatActorSchema).optional(),
- crownJewels: z.array(crownJewelSchema).optional(),
+ targets: z.array(targetSchema).optional(),
tags: z.array(tagSchema).optional(),
toolCategories: z.array(toolCategorySchema).optional(),
tools: z.array(toolSchema).optional(),
@@ -143,7 +151,7 @@ export const dataRouter = createTRPCRouter({
techniqueCount,
outcomeCount,
threatActorCount,
- crownJewelCount,
+ targetCount,
tagCount,
toolCount,
logSourceCount,
@@ -152,7 +160,7 @@ export const dataRouter = createTRPCRouter({
db.technique.count(),
db.outcome.count(),
db.threatActor.count(),
- db.crownJewel.count(),
+ db.target.count(),
db.tag.count(),
db.tool.count(),
db.logSource.count(),
@@ -163,7 +171,7 @@ export const dataRouter = createTRPCRouter({
techniques: techniqueCount,
outcomes: outcomeCount,
threatActors: threatActorCount,
- crownJewels: crownJewelCount,
+ targets: targetCount,
tags: tagCount,
tools: toolCount,
logSources: logSourceCount,
@@ -175,7 +183,7 @@ export const dataRouter = createTRPCRouter({
try {
const [
- crownJewels,
+ targets,
tags,
toolCategories,
tools,
@@ -185,7 +193,7 @@ export const dataRouter = createTRPCRouter({
outcomes,
attackFlowLayouts,
] = await Promise.all([
- db.crownJewel.findMany(),
+ db.target.findMany(),
db.tag.findMany(),
db.toolCategory.findMany(),
db.tool.findMany(),
@@ -193,12 +201,18 @@ export const dataRouter = createTRPCRouter({
db.operation.findMany({
include: {
tags: { select: { id: true } },
- crownJewels: { select: { id: true } },
+ targets: { select: { id: true } },
},
}),
db.technique.findMany({
include: {
tools: { select: { id: true } },
+ targetEngagements: {
+ select: {
+ targetId: true,
+ wasSuccessful: true,
+ },
+ },
},
}),
db.outcome.findMany({
@@ -222,19 +236,32 @@ export const dataRouter = createTRPCRouter({
})),
);
+ const techniquesPayload = techniques.map(({ targetEngagements, ...technique }) => ({
+ ...technique,
+ targetEngagements: targetEngagements?.map((engagement) => ({
+ targetId: engagement.targetId,
+ status:
+ engagement.wasSuccessful === null || engagement.wasSuccessful === undefined
+ ? "unknown"
+ : engagement.wasSuccessful
+ ? "succeeded"
+ : "failed",
+ })),
+ }));
+
return JSON.stringify(
{
version: "2.0",
timestamp: new Date().toISOString(),
data: {
threatActors,
- crownJewels,
+ targets,
tags,
toolCategories,
tools,
logSources,
operations,
- techniques,
+ techniques: techniquesPayload,
outcomes,
attackFlowLayouts,
threatActorTechniqueLinks,
@@ -281,8 +308,8 @@ export const dataRouter = createTRPCRouter({
if (payload.threatActors?.length) {
await tx.threatActor.createMany({ data: payload.threatActors });
}
- if (payload.crownJewels?.length) {
- await tx.crownJewel.createMany({ data: payload.crownJewels });
+ if (payload.targets?.length) {
+ await tx.target.createMany({ data: payload.targets });
}
if (payload.tags?.length) {
await tx.tag.createMany({ data: payload.tags });
@@ -298,7 +325,7 @@ export const dataRouter = createTRPCRouter({
}
for (const op of payload.operations ?? []) {
- const { tags: opTags = [], crownJewels: opCrownJewels = [], ...operationFields } = op;
+ const { tags: opTags = [], targets: opTargets = [], ...operationFields } = op;
await tx.operation.create({
data: {
@@ -306,18 +333,35 @@ export const dataRouter = createTRPCRouter({
// Access groups are not restored; default all operations to everyone-visible.
visibility: "EVERYONE",
tags: opTags.length ? { connect: opTags.map(({ id }) => ({ id })) } : undefined,
- crownJewels: opCrownJewels.length ? { connect: opCrownJewels.map(({ id }) => ({ id })) } : undefined,
+ targets: opTargets.length ? { connect: opTargets.map(({ id }) => ({ id })) } : undefined,
},
});
}
for (const technique of payload.techniques ?? []) {
- const { tools: techniqueTools = [], ...techniqueFields } = technique;
+ const {
+ tools: techniqueTools = [],
+ targetEngagements: engagementPayload = [],
+ ...techniqueFields
+ } = technique;
await tx.technique.create({
data: {
...techniqueFields,
tools: techniqueTools.length ? { connect: techniqueTools.map(({ id }) => ({ id })) } : undefined,
+ targetEngagements: engagementPayload.length
+ ? {
+ create: engagementPayload.map((engagement) => ({
+ targetId: engagement.targetId,
+ wasSuccessful:
+ engagement.status === undefined
+ ? null
+ : engagement.status === "unknown"
+ ? null
+ : engagement.status === "succeeded",
+ })),
+ }
+ : undefined,
},
});
}
diff --git a/src/server/api/routers/operations.ts b/src/server/api/routers/operations.ts
index accd29c..bbe7e4c 100644
--- a/src/server/api/routers/operations.ts
+++ b/src/server/api/routers/operations.ts
@@ -13,7 +13,7 @@ const createOperationSchema = z.object({
description: z.string().min(1, "Description is required"),
threatActorId: z.string().optional(),
tagIds: z.array(z.string()).optional(),
- crownJewelIds: z.array(z.string()).optional(),
+ targetIds: z.array(z.string()).optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
visibility: z.nativeEnum(OperationVisibility).optional(),
@@ -27,7 +27,7 @@ const updateOperationSchema = z.object({
status: z.nativeEnum(OperationStatus).optional(),
threatActorId: z.string().optional(),
tagIds: z.array(z.string()).optional(),
- crownJewelIds: z.array(z.string()).optional(),
+ targetIds: z.array(z.string()).optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
visibility: z.nativeEnum(OperationVisibility).optional(),
@@ -80,7 +80,7 @@ export const operationsRouter = createTRPCRouter({
createdBy: { select: { id: true, name: true, email: true } },
threatActor: true,
tags: true,
- crownJewels: true,
+ targets: true,
accessGroups: { include: { group: true } },
techniques: {
include: {
@@ -93,6 +93,11 @@ export const operationsRouter = createTRPCRouter({
logSources: true,
},
},
+ targetEngagements: {
+ include: {
+ target: true,
+ },
+ },
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
@@ -153,13 +158,16 @@ export const operationsRouter = createTRPCRouter({
createdBy: { select: { id: true, name: true, email: true } },
threatActor: true,
tags: true,
- crownJewels: true,
+ targets: true,
accessGroups: { include: { group: true } },
techniques: {
include: {
mitreTechnique: true,
mitreSubTechnique: true,
outcomes: true,
+ targetEngagements: {
+ include: { target: true },
+ },
},
},
},
diff --git a/src/server/api/routers/taxonomy/crown-jewels.ts b/src/server/api/routers/taxonomy/crown-jewels.ts
deleted file mode 100644
index 1d4bf5b..0000000
--- a/src/server/api/routers/taxonomy/crown-jewels.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { z } from "zod";
-import { TRPCError } from "@trpc/server";
-import { UserRole } from "@prisma/client";
-import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
-import { createCrownJewel, updateCrownJewel, deleteCrownJewel } from "@/server/services/taxonomyService";
-import { getCrownJewelUsageCount } from "@/server/services/usageService";
-import { auditEvent, logger } from "@/server/logger";
-
-const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
- if (ctx.session.user.role !== UserRole.ADMIN) {
- throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
- }
- return next();
-});
-
-export const crownJewelsRouter = createTRPCRouter({
- list: protectedProcedure.query(async ({ ctx }) => {
- const items = await ctx.db.crownJewel.findMany({ orderBy: { name: "asc" } });
- const withUsage = await Promise.all(items.map(async (i) => ({ ...i, usageCount: await getCrownJewelUsageCount(ctx.db, i.id) })));
- return withUsage;
- }),
-
- create: adminProcedure
- .input(z.object({ name: z.string().min(1), description: z.string() }))
- .mutation(async ({ ctx, input }) => {
- const cj = await createCrownJewel(ctx.db, input);
- logger.info(
- auditEvent(ctx, "sec.taxonomy.crownJewel.create", { id: cj.id, name: cj.name }),
- "Crown jewel created",
- );
- return cj;
- }),
-
- update: adminProcedure
- .input(z.object({ id: z.string(), name: z.string().min(1).optional(), description: z.string().optional() }))
- .mutation(async ({ ctx, input }) => {
- const updated = await updateCrownJewel(ctx.db, input);
- logger.info(
- auditEvent(ctx, "sec.taxonomy.crownJewel.update", { id: input.id, name: updated.name }),
- "Crown jewel updated",
- );
- return updated;
- }),
-
- delete: adminProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ ctx, input }) => {
- const res = await deleteCrownJewel(ctx.db, input.id);
- logger.info(
- auditEvent(ctx, "sec.taxonomy.crownJewel.delete", { id: input.id, name: res.name }),
- "Crown jewel deleted",
- );
- return res;
- }),
-});
diff --git a/src/server/api/routers/taxonomy/index.ts b/src/server/api/routers/taxonomy/index.ts
index c279b2b..57210c9 100644
--- a/src/server/api/routers/taxonomy/index.ts
+++ b/src/server/api/routers/taxonomy/index.ts
@@ -1,6 +1,6 @@
import { createTRPCRouter } from "@/server/api/trpc";
import { threatActorsRouter } from "./threat-actors";
-import { crownJewelsRouter } from "./crown-jewels";
+import { targetsRouter } from "./targets";
import { tagsRouter } from "./tags";
import { toolCategoriesRouter } from "./tool-categories";
import { toolsRouter } from "./tools";
@@ -9,7 +9,7 @@ import { mitreRouter } from "./mitre";
export const taxonomyRouter = createTRPCRouter({
threatActors: threatActorsRouter,
- crownJewels: crownJewelsRouter,
+ targets: targetsRouter,
tags: tagsRouter,
toolCategories: toolCategoriesRouter,
tools: toolsRouter,
diff --git a/src/server/api/routers/taxonomy/targets.ts b/src/server/api/routers/taxonomy/targets.ts
new file mode 100644
index 0000000..39bb084
--- /dev/null
+++ b/src/server/api/routers/taxonomy/targets.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { UserRole } from "@prisma/client";
+import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
+import { createTarget, updateTarget, deleteTarget } from "@/server/services/taxonomyService";
+import { getTargetUsageCount } from "@/server/services/usageService";
+import { auditEvent, logger } from "@/server/logger";
+
+const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
+ if (ctx.session.user.role !== UserRole.ADMIN) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
+ }
+ return next();
+});
+
+export const targetsRouter = createTRPCRouter({
+ list: protectedProcedure.query(async ({ ctx }) => {
+ const items = await ctx.db.target.findMany({ orderBy: { name: "asc" } });
+ const withUsage = await Promise.all(
+ items.map(async (item) => ({ ...item, usageCount: await getTargetUsageCount(ctx.db, item.id) })),
+ );
+ return withUsage;
+ }),
+
+ create: adminProcedure
+ .input(z.object({ name: z.string().min(1), description: z.string(), isCrownJewel: z.boolean().optional() }))
+ .mutation(async ({ ctx, input }) => {
+ const target = await createTarget(ctx.db, input);
+ logger.info(
+ auditEvent(ctx, "sec.taxonomy.target.create", { id: target.id, name: target.name }),
+ "Target created",
+ );
+ return target;
+ }),
+
+ update: adminProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1).optional(),
+ description: z.string().optional(),
+ isCrownJewel: z.boolean().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const updated = await updateTarget(ctx.db, input);
+ logger.info(
+ auditEvent(ctx, "sec.taxonomy.target.update", { id: input.id, name: updated.name }),
+ "Target updated",
+ );
+ return updated;
+ }),
+
+ delete: adminProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const res = await deleteTarget(ctx.db, input.id);
+ logger.info(
+ auditEvent(ctx, "sec.taxonomy.target.delete", { id: input.id, name: res.name }),
+ "Target deleted",
+ );
+ return res;
+ }),
+});
diff --git a/src/server/api/routers/techniques.ts b/src/server/api/routers/techniques.ts
index 57ee42d..20b83f4 100644
--- a/src/server/api/routers/techniques.ts
+++ b/src/server/api/routers/techniques.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import type { Prisma } from "@prisma/client";
import { createTRPCRouter, protectedProcedure, viewerProcedure } from "@/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { checkOperationAccess, getAccessibleOperationFilter } from "@/server/api/access";
@@ -6,6 +7,11 @@ import { createTechniqueWithValidations } from "@/server/services/techniqueServi
import { auditEvent, logger } from "@/server/logger";
// Input validation schemas
+const targetEngagementSchema = z.object({
+ targetId: z.string(),
+ status: z.enum(["unknown", "succeeded", "failed"]),
+});
+
const createTechniqueSchema = z.object({
operationId: z.number(),
description: z
@@ -19,10 +25,9 @@ const createTechniqueSchema = z.object({
endTime: z.date().optional(),
sourceIp: z.string().optional(),
targetSystem: z.string().optional(),
- crownJewelTargeted: z.boolean().default(false),
- crownJewelCompromised: z.boolean().default(false),
toolIds: z.array(z.string()).optional(),
executedSuccessfully: z.boolean().optional(),
+ targetEngagements: z.array(targetEngagementSchema).default([]),
});
const updateTechniqueSchema = z.object({
@@ -35,10 +40,9 @@ const updateTechniqueSchema = z.object({
endTime: z.date().nullable().optional(),
sourceIp: z.string().optional(),
targetSystem: z.string().optional(),
- crownJewelTargeted: z.boolean().optional(),
- crownJewelCompromised: z.boolean().optional(),
toolIds: z.array(z.string()).optional(),
executedSuccessfully: z.boolean().nullable().optional(),
+ targetEngagements: z.array(targetEngagementSchema).optional(),
});
const getTechniqueSchema = z.object({
@@ -66,8 +70,12 @@ export const techniquesRouter = createTRPCRouter({
});
}
- const { toolIds, ...rest } = input;
- const created = await createTechniqueWithValidations(ctx.db, { ...rest, toolIds });
+ const { toolIds, targetEngagements, ...rest } = input;
+ const created = await createTechniqueWithValidations(ctx.db, {
+ ...rest,
+ toolIds,
+ targetEngagements,
+ });
logger.info(
auditEvent(ctx, "sec.technique.create", {
techniqueId: created.id,
@@ -85,7 +93,7 @@ export const techniquesRouter = createTRPCRouter({
update: protectedProcedure
.input(updateTechniqueSchema)
.mutation(async ({ ctx, input }) => {
- const { id, toolIds, ...updateData } = input;
+ const { id, toolIds, targetEngagements, ...updateData } = input;
// Check if technique exists
const existingTechnique = await ctx.db.technique.findUnique({
@@ -158,6 +166,20 @@ export const techniquesRouter = createTRPCRouter({
}
}
+ if (input.targetEngagements && input.targetEngagements.length > 0) {
+ const targetIds = input.targetEngagements.map((engagement) => engagement.targetId);
+ if (new Set(targetIds).size !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Duplicate targets are not allowed" });
+ }
+ const existingTargets = await ctx.db.target.findMany({
+ where: { id: { in: targetIds } },
+ select: { id: true },
+ });
+ if (existingTargets.length !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "One or more targets not found" });
+ }
+ }
+
// Validate end time is not before start time
if (input.startTime && input.endTime && input.endTime < input.startTime) {
throw new TRPCError({
@@ -169,6 +191,7 @@ export const techniquesRouter = createTRPCRouter({
// Prepare update data with relationship updates
const updatePayload: typeof updateData & {
tools?: { set: { id: string }[] };
+ targetEngagements?: Prisma.TechniqueTargetUpdateManyWithoutTechniqueNestedInput;
} = { ...updateData };
if (toolIds !== undefined) {
@@ -177,6 +200,31 @@ export const techniquesRouter = createTRPCRouter({
};
}
+ if (targetEngagements !== undefined) {
+ const statusToValue = (status: "unknown" | "succeeded" | "failed") => {
+ if (status === "unknown") return null;
+ return status === "succeeded";
+ };
+
+ if (targetEngagements.length === 0) {
+ updatePayload.targetEngagements = { deleteMany: {} };
+ } else {
+ updatePayload.targetEngagements = {
+ deleteMany: {
+ targetId: { notIn: targetEngagements.map((engagement) => engagement.targetId) },
+ },
+ upsert: targetEngagements.map((engagement) => ({
+ where: { techniqueId_targetId: { techniqueId: id, targetId: engagement.targetId } },
+ update: { wasSuccessful: statusToValue(engagement.status) },
+ create: {
+ target: { connect: { id: engagement.targetId } },
+ wasSuccessful: statusToValue(engagement.status),
+ },
+ })),
+ };
+ }
+ }
+
const updated = await ctx.db.technique.update({
where: { id },
data: updatePayload,
@@ -195,6 +243,9 @@ export const techniquesRouter = createTRPCRouter({
logSources: true,
},
},
+ targetEngagements: {
+ include: { target: true },
+ },
},
});
logger.info(
diff --git a/src/server/services/operationService.ts b/src/server/services/operationService.ts
index 0daafd8..57ef189 100644
--- a/src/server/services/operationService.ts
+++ b/src/server/services/operationService.ts
@@ -6,7 +6,7 @@ export interface OperationCreateInput {
description: string;
threatActorId?: string;
tagIds?: string[];
- crownJewelIds?: string[];
+ targetIds?: string[];
startDate?: Date;
endDate?: Date;
visibility?: OperationVisibility;
@@ -19,7 +19,7 @@ export async function createOperationWithValidations(params: {
input: OperationCreateInput;
}) {
const { db, user, input } = params;
- const { tagIds, crownJewelIds, accessGroupIds, visibility, ...operationData } = input;
+ const { tagIds, targetIds, accessGroupIds, visibility, ...operationData } = input;
// Verify threat actor exists if provided
if (input.threatActorId) {
@@ -39,11 +39,11 @@ export async function createOperationWithValidations(params: {
}
}
- // Verify crown jewels exist if provided
- if (crownJewelIds && crownJewelIds.length > 0) {
- const existingCrownJewels = await db.crownJewel.findMany({ where: { id: { in: crownJewelIds } } });
- if (existingCrownJewels.length !== crownJewelIds.length) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "One or more crown jewels not found" });
+ // Verify targets exist if provided
+ if (targetIds && targetIds.length > 0) {
+ const existingTargets = await db.target.findMany({ where: { id: { in: targetIds } } });
+ if (existingTargets.length !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "One or more targets not found" });
}
}
@@ -83,13 +83,13 @@ export async function createOperationWithValidations(params: {
visibility: effectiveVisibility,
accessGroups: accessGroupsCreate ? { create: accessGroupsCreate } : undefined,
tags: tagIds ? { connect: tagIds.map((id) => ({ id })) } : undefined,
- crownJewels: crownJewelIds ? { connect: crownJewelIds.map((id) => ({ id })) } : undefined,
+ targets: targetIds ? { connect: targetIds.map((id) => ({ id })) } : undefined,
},
include: {
createdBy: { select: { id: true, name: true, email: true } },
threatActor: true,
tags: true,
- crownJewels: true,
+ targets: true,
accessGroups: { include: { group: true } },
techniques: {
include: {
@@ -109,7 +109,7 @@ export interface OperationUpdateDTO {
status?: OperationStatus;
threatActorId?: string;
tagIds?: string[];
- crownJewelIds?: string[];
+ targetIds?: string[];
startDate?: Date;
endDate?: Date;
visibility?: OperationVisibility;
@@ -122,7 +122,7 @@ export async function updateOperationWithValidations(params: {
input: OperationUpdateDTO;
}) {
const { db, user, input } = params;
- const { id, tagIds, crownJewelIds, accessGroupIds, visibility, ...updateData } = input;
+ const { id, tagIds, targetIds, accessGroupIds, visibility, ...updateData } = input;
const existingOperation = await db.operation.findUnique({
where: { id },
@@ -148,10 +148,10 @@ export async function updateOperationWithValidations(params: {
}
}
- if (crownJewelIds && crownJewelIds.length > 0) {
- const existingCrownJewels = await db.crownJewel.findMany({ where: { id: { in: crownJewelIds } } });
- if (existingCrownJewels.length !== crownJewelIds.length) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "One or more crown jewels not found" });
+ if (targetIds && targetIds.length > 0) {
+ const existingTargets = await db.target.findMany({ where: { id: { in: targetIds } } });
+ if (existingTargets.length !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "One or more targets not found" });
}
}
@@ -163,7 +163,7 @@ export async function updateOperationWithValidations(params: {
if (updateData.startDate !== undefined) updatePayload.startDate = updateData.startDate;
if (updateData.endDate !== undefined) updatePayload.endDate = updateData.endDate;
if (tagIds !== undefined) updatePayload.tags = { set: tagIds.map((id) => ({ id })) };
- if (crownJewelIds !== undefined) updatePayload.crownJewels = { set: crownJewelIds.map((id) => ({ id })) };
+ if (targetIds !== undefined) updatePayload.targets = { set: targetIds.map((id) => ({ id })) };
const nextVisibility = visibility ?? existingOperation.visibility;
let resultingGroupIds: string[] = existingOperation.accessGroups.map((ag) => ag.groupId);
@@ -214,7 +214,7 @@ export async function updateOperationWithValidations(params: {
createdBy: { select: { id: true, name: true, email: true } },
threatActor: true,
tags: true,
- crownJewels: true,
+ targets: true,
accessGroups: { include: { group: true } },
techniques: { include: { mitreTechnique: true, mitreSubTechnique: true, outcomes: true } },
},
diff --git a/src/server/services/taxonomyService.ts b/src/server/services/taxonomyService.ts
index 73d4c38..060dc6e 100644
--- a/src/server/services/taxonomyService.ts
+++ b/src/server/services/taxonomyService.ts
@@ -1,25 +1,25 @@
import { TRPCError } from "@trpc/server";
import type { PrismaClient, ToolType } from "@prisma/client";
-// Crown Jewels
-export type CreateCrownJewelDTO = { name: string; description: string };
-export type UpdateCrownJewelDTO = { id: string; name?: string; description?: string };
+// Targets
+export type CreateTargetDTO = { name: string; description: string; isCrownJewel?: boolean };
+export type UpdateTargetDTO = { id: string; name?: string; description?: string; isCrownJewel?: boolean };
-export async function createCrownJewel(db: PrismaClient, dto: CreateCrownJewelDTO) {
- return db.crownJewel.create({ data: dto });
+export async function createTarget(db: PrismaClient, dto: CreateTargetDTO) {
+ return db.target.create({ data: { ...dto, isCrownJewel: dto.isCrownJewel ?? false } });
}
-export async function updateCrownJewel(db: PrismaClient, dto: UpdateCrownJewelDTO) {
- const { id, ...data } = dto;
- return db.crownJewel.update({ where: { id }, data });
+export async function updateTarget(db: PrismaClient, dto: UpdateTargetDTO) {
+ const { id, isCrownJewel, ...data } = dto;
+ return db.target.update({ where: { id }, data: { ...data, ...(isCrownJewel !== undefined ? { isCrownJewel } : {}) } });
}
-export async function deleteCrownJewel(db: PrismaClient, id: string) {
- const operationsCount = await db.operation.count({ where: { crownJewels: { some: { id } } } });
+export async function deleteTarget(db: PrismaClient, id: string) {
+ const operationsCount = await db.operation.count({ where: { targets: { some: { id } } } });
if (operationsCount > 0) {
- throw new TRPCError({ code: "BAD_REQUEST", message: `Cannot delete crown jewel: ${operationsCount} operation(s) are using this crown jewel` });
+ throw new TRPCError({ code: "BAD_REQUEST", message: `Cannot delete target: ${operationsCount} operation(s) are using this target` });
}
- return db.crownJewel.delete({ where: { id } });
+ return db.target.delete({ where: { id } });
}
// Tags
diff --git a/src/server/services/techniqueService.ts b/src/server/services/techniqueService.ts
index efef2d7..3333889 100644
--- a/src/server/services/techniqueService.ts
+++ b/src/server/services/techniqueService.ts
@@ -19,10 +19,12 @@ export interface TechniqueCreateInput {
endTime?: Date | null;
sourceIp?: string;
targetSystem?: string;
- crownJewelTargeted?: boolean;
- crownJewelCompromised?: boolean;
executedSuccessfully?: boolean | null;
toolIds?: string[];
+ targetEngagements?: Array<{
+ targetId: string;
+ status: "succeeded" | "failed" | "unknown";
+ }>;
}
export async function createTechniqueWithValidations(db: PrismaClient, input: TechniqueCreateInput) {
@@ -64,9 +66,25 @@ export async function createTechniqueWithValidations(db: PrismaClient, input: Te
}
}
+ if (input.targetEngagements && input.targetEngagements.length > 0) {
+ const targetIds = input.targetEngagements.map((engagement) => engagement.targetId);
+ if (new Set(targetIds).size !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Duplicate targets are not allowed" });
+ }
+ const existingTargets = await db.target.findMany({ where: { id: { in: targetIds } }, select: { id: true } });
+ if (existingTargets.length !== targetIds.length) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "One or more targets not found" });
+ }
+ }
+
// Compute next sort order
const nextSort = await getNextTechniqueSortOrder(db, input.operationId);
+ const formatStatus = (status: "succeeded" | "failed" | "unknown") => {
+ if (status === "unknown") return null;
+ return status === "succeeded";
+ };
+
return db.technique.create({
data: {
operationId: input.operationId,
@@ -76,12 +94,18 @@ export async function createTechniqueWithValidations(db: PrismaClient, input: Te
endTime: input.endTime ?? undefined,
sourceIp: input.sourceIp,
targetSystem: input.targetSystem,
- crownJewelTargeted: input.crownJewelTargeted ?? false,
- crownJewelCompromised: input.crownJewelCompromised ?? false,
executedSuccessfully: input.executedSuccessfully ?? undefined,
mitreTechniqueId: input.mitreTechniqueId,
mitreSubTechniqueId: input.mitreSubTechniqueId,
tools: input.toolIds ? { connect: input.toolIds.map((id) => ({ id })) } : undefined,
+ targetEngagements: input.targetEngagements?.length
+ ? {
+ create: input.targetEngagements.map((engagement) => ({
+ target: { connect: { id: engagement.targetId } },
+ wasSuccessful: formatStatus(engagement.status),
+ })),
+ }
+ : undefined,
},
include: {
operation: { select: { id: true, name: true } },
@@ -94,6 +118,11 @@ export async function createTechniqueWithValidations(db: PrismaClient, input: Te
logSources: true,
},
},
+ targetEngagements: {
+ include: {
+ target: true,
+ },
+ },
},
});
}
diff --git a/src/server/services/usageService.ts b/src/server/services/usageService.ts
index fc0305c..089be4a 100644
--- a/src/server/services/usageService.ts
+++ b/src/server/services/usageService.ts
@@ -4,8 +4,8 @@ export async function getThreatActorUsageCount(db: PrismaClient, threatActorId:
return db.operation.count({ where: { threatActorId } });
}
-export async function getCrownJewelUsageCount(db: PrismaClient, crownJewelId: string): Promise {
- return db.operation.count({ where: { crownJewels: { some: { id: crownJewelId } } } });
+export async function getTargetUsageCount(db: PrismaClient, targetId: string): Promise {
+ return db.operation.count({ where: { targets: { some: { id: targetId } } } });
}
export async function getTagUsageCount(db: PrismaClient, tagId: string): Promise {
diff --git a/src/test/analytics-scorecard.test.ts b/src/test/analytics-scorecard.test.ts
index a723a70..6643949 100644
--- a/src/test/analytics-scorecard.test.ts
+++ b/src/test/analytics-scorecard.test.ts
@@ -33,8 +33,6 @@ describe("Scorecard metrics", () => {
id: "tech1",
startTime: new Date("2024-01-02T00:00:00Z"),
executedSuccessfully: true,
- crownJewelTargeted: true,
- crownJewelCompromised: true,
operationId: 1,
operation: { id: 1, name: "Operation 1", threatActorId: "ta1" },
mitreTechnique: { tactic: { id: "TA0001", name: "Initial Access" } },
@@ -50,13 +48,18 @@ describe("Scorecard metrics", () => {
detectionTime: null,
},
],
+ targetEngagements: [
+ {
+ targetId: "cj-1",
+ wasSuccessful: true,
+ target: { id: "cj-1", name: "CJ", isCrownJewel: true },
+ },
+ ],
},
{
id: "tech2",
startTime: new Date("2024-01-03T00:00:00Z"),
executedSuccessfully: false,
- crownJewelTargeted: true,
- crownJewelCompromised: false,
operationId: 2,
operation: { id: 2, name: "Operation 2", threatActorId: "ta2" },
mitreTechnique: { tactic: { id: "TA0002", name: "Execution" } },
@@ -72,28 +75,33 @@ describe("Scorecard metrics", () => {
detectionTime: new Date("2024-01-03T02:00:00Z"),
},
],
+ targetEngagements: [
+ {
+ targetId: "cj-1",
+ wasSuccessful: false,
+ target: { id: "cj-1", name: "CJ", isCrownJewel: true },
+ },
+ ],
},
{
id: "tech3",
startTime: new Date("2024-01-04T00:00:00Z"),
executedSuccessfully: null,
- crownJewelTargeted: false,
- crownJewelCompromised: false,
operationId: 1,
operation: { id: 1, name: "Operation 1", threatActorId: "ta1" },
mitreTechnique: { tactic: { id: "TA0001", name: "Initial Access" } },
outcomes: [],
+ targetEngagements: [],
},
{
id: "tech4",
startTime: null,
executedSuccessfully: null,
- crownJewelTargeted: false,
- crownJewelCompromised: false,
operationId: 1,
operation: { id: 1, name: "Operation 1", threatActorId: "ta1" },
mitreTechnique: { tactic: { id: "TA0003", name: "Persistence" } },
outcomes: [],
+ targetEngagements: [],
},
]);
mockDb.tool.findMany
@@ -156,8 +164,6 @@ describe("Scorecard metrics", () => {
id: "tech1",
startTime: null,
executedSuccessfully: null,
- crownJewelTargeted: false,
- crownJewelCompromised: false,
operationId: 1,
operation: { id: 1, name: "Operation 1", threatActorId: null },
mitreTechnique: { tactic: { id: "TA0001", name: "Initial Access" } },
@@ -173,6 +179,7 @@ describe("Scorecard metrics", () => {
detectionTime: null,
},
],
+ targetEngagements: [],
},
]);
mockDb.tool.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
diff --git a/src/test/data-restore.test.ts b/src/test/data-restore.test.ts
index 5264025..fb47e4f 100644
--- a/src/test/data-restore.test.ts
+++ b/src/test/data-restore.test.ts
@@ -7,13 +7,14 @@ vi.mock("@/server/db", () => ({
db: {
$transaction: vi.fn(),
threatActor: { createMany: vi.fn(), deleteMany: vi.fn(), update: vi.fn() },
- crownJewel: { createMany: vi.fn(), deleteMany: vi.fn() },
+ target: { createMany: vi.fn(), deleteMany: vi.fn() },
tag: { createMany: vi.fn(), deleteMany: vi.fn() },
toolCategory: { createMany: vi.fn(), deleteMany: vi.fn() },
tool: { createMany: vi.fn(), deleteMany: vi.fn() },
logSource: { createMany: vi.fn(), deleteMany: vi.fn() },
operation: { create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
technique: { create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
+ techniqueTarget: { deleteMany: vi.fn() },
outcome: { create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
attackFlowLayout: { create: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() },
},
@@ -45,13 +46,13 @@ describe("Data Restore", () => {
const payload = {
threatActors: [{ id: "ta1", name: "APT29", description: "desc" }],
- crownJewels: [{ id: "cj1", name: "DB", description: "desc" }],
+ targets: [{ id: "cj1", name: "DB", description: "desc", isCrownJewel: true }],
tags: [{ id: "tag1", name: "Stealth", description: "d" }],
toolCategories: [{ id: "cat1", name: "EDR", type: "DEFENSIVE" as const }],
tools: [{ id: "tool1", name: "Falcon", categoryId: "cat1", type: "DEFENSIVE" as const }],
logSources: [{ id: "log1", name: "SIEM", description: "d" }],
operations: [
- { id: 1, name: "Op1", description: "d", createdById: "u1", tags: [{ id: "tag1" }], crownJewels: [{ id: "cj1" }] },
+ { id: 1, name: "Op1", description: "d", createdById: "u1", tags: [{ id: "tag1" }], targets: [{ id: "cj1" }] },
],
techniques: [{ id: "tech-inst", description: "d", operationId: 1, tools: [{ id: "tool1" }] }],
outcomes: [
diff --git a/src/test/data.test.ts b/src/test/data.test.ts
index e9eb214..29e9447 100644
--- a/src/test/data.test.ts
+++ b/src/test/data.test.ts
@@ -8,8 +8,9 @@ vi.mock("@/server/db", () => ({
operation: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
technique: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
outcome: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
+ techniqueTarget: { deleteMany: vi.fn() },
threatActor: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
- crownJewel: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
+ target: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
tag: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
tool: { count: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
toolCategory: { findMany: vi.fn(), deleteMany: vi.fn() },
@@ -44,7 +45,7 @@ describe("Data Router", () => {
mockDb.technique.count.mockResolvedValue(9);
mockDb.outcome.count.mockResolvedValue(5);
mockDb.threatActor.count.mockResolvedValue(3);
- mockDb.crownJewel.count.mockResolvedValue(2);
+ mockDb.target.count.mockResolvedValue(2);
mockDb.tag.count.mockResolvedValue(6);
mockDb.tool.count.mockResolvedValue(7);
mockDb.logSource.count.mockResolvedValue(8);
@@ -54,7 +55,7 @@ describe("Data Router", () => {
techniques: 9,
outcomes: 5,
threatActors: 3,
- crownJewels: 2,
+ targets: 2,
tags: 6,
tools: 7,
logSources: 8,
@@ -72,7 +73,7 @@ describe("Data Router", () => {
it("creates a backup payload for admins", async () => {
const caller = createCaller(UserRole.ADMIN);
- mockDb.crownJewel.findMany.mockResolvedValue([]);
+ mockDb.target.findMany.mockResolvedValue([]);
mockDb.tag.findMany.mockResolvedValue([]);
mockDb.toolCategory.findMany.mockResolvedValue([]);
mockDb.tool.findMany.mockResolvedValue([]);
@@ -109,6 +110,7 @@ describe("Data Router", () => {
await caller.clearData();
expect(mockDb.outcome.deleteMany).toHaveBeenCalled();
+ expect(mockDb.techniqueTarget.deleteMany).toHaveBeenCalled();
expect(mockDb.technique.deleteMany).toHaveBeenCalled();
expect(mockDb.attackFlowLayout.deleteMany).toHaveBeenCalled();
expect(mockDb.operation.deleteMany).toHaveBeenCalled();
@@ -116,7 +118,7 @@ describe("Data Router", () => {
expect(mockDb.toolCategory.deleteMany).toHaveBeenCalled();
expect(mockDb.logSource.deleteMany).toHaveBeenCalled();
expect(mockDb.tag.deleteMany).toHaveBeenCalled();
- expect(mockDb.crownJewel.deleteMany).toHaveBeenCalled();
+ expect(mockDb.target.deleteMany).toHaveBeenCalled();
expect(mockDb.threatActor.deleteMany).toHaveBeenCalled();
});
diff --git a/src/test/factories/analytics.ts b/src/test/factories/analytics.ts
index 4fcb1c7..eb62379 100644
--- a/src/test/factories/analytics.ts
+++ b/src/test/factories/analytics.ts
@@ -6,6 +6,7 @@ export type CoverageTechnique = Prisma.TechniqueGetPayload<{
mitreTechnique: { include: { tactic: true } };
operation: { select: { id: true; name: true; status: true } };
outcomes: { include: { tools: true; logSources: true } };
+ targetEngagements: { include: { target: true } };
};
}>;
@@ -13,6 +14,7 @@ export type TechniqueWithSubTechnique = Prisma.TechniqueGetPayload<{
include: {
mitreSubTechnique: { include: { technique: { include: { tactic: true } } } };
outcomes: { include: { tools: true; logSources: true } };
+ targetEngagements: { include: { target: true } };
};
}>;
@@ -82,8 +84,6 @@ export function buildCoverageTechnique(
endTime: overrides.endTime ?? null,
sourceIp: overrides.sourceIp ?? null,
targetSystem: overrides.targetSystem ?? null,
- crownJewelTargeted: overrides.crownJewelTargeted ?? false,
- crownJewelCompromised: overrides.crownJewelCompromised ?? false,
executedSuccessfully: overrides.executedSuccessfully ?? null,
operationId,
mitreTechniqueId: overrides.mitreTechniqueId ?? overrides.mitreTechnique?.id ?? "T1566",
@@ -106,6 +106,7 @@ export function buildCoverageTechnique(
tactic,
},
outcomes: overrides.outcomes ?? [],
+ targetEngagements: overrides.targetEngagements ?? [],
};
return {
@@ -171,8 +172,6 @@ export function buildTechniqueWithSubTechnique(
endTime: overrides.endTime ?? null,
sourceIp: overrides.sourceIp ?? null,
targetSystem: overrides.targetSystem ?? null,
- crownJewelTargeted: overrides.crownJewelTargeted ?? false,
- crownJewelCompromised: overrides.crownJewelCompromised ?? false,
executedSuccessfully: overrides.executedSuccessfully ?? null,
operationId: overrides.operationId ?? 1,
mitreTechniqueId: overrides.mitreTechniqueId ?? null,
@@ -181,6 +180,7 @@ export function buildTechniqueWithSubTechnique(
updatedAt: overrides.updatedAt ?? now,
outcomes: overrides.outcomes ?? [],
mitreSubTechnique: subTechniqueBase,
+ targetEngagements: overrides.targetEngagements ?? [],
};
return {
diff --git a/src/test/factories/taxonomy.ts b/src/test/factories/taxonomy.ts
index 66c9ab9..aa7d8c3 100644
--- a/src/test/factories/taxonomy.ts
+++ b/src/test/factories/taxonomy.ts
@@ -6,11 +6,12 @@ export function buildTag(overrides: Partial<{ id: string; name: string; color: s
};
}
-export function buildCrownJewel(overrides: Partial<{ id: string; name: string; description?: string | null }> = {}) {
+export function buildTarget(overrides: Partial<{ id: string; name: string; description?: string | null; isCrownJewel?: boolean }> = {}) {
return {
- id: overrides.id ?? "cj-1",
- name: overrides.name ?? "Crown Jewel",
+ id: overrides.id ?? "target-1",
+ name: overrides.name ?? "Target",
description: overrides.description ?? null,
+ isCrownJewel: overrides.isCrownJewel ?? false,
};
}
diff --git a/src/test/operation-service.test.ts b/src/test/operation-service.test.ts
index b3edfb3..29d226c 100644
--- a/src/test/operation-service.test.ts
+++ b/src/test/operation-service.test.ts
@@ -8,7 +8,7 @@ const user = { id: "u1", role: "OPERATOR" as UserRole };
const mockDb = {
threatActor: { findUnique: vi.fn() },
tag: { findMany: vi.fn() },
- crownJewel: { findMany: vi.fn() },
+ target: { findMany: vi.fn() },
group: { findMany: vi.fn() },
userGroup: { count: vi.fn() },
operation: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
@@ -35,15 +35,15 @@ describe("operationService", () => {
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "One or more tags not found" }));
});
- it("throws on create when crown jewel missing", async () => {
- mockDb.crownJewel.findMany.mockResolvedValue([]);
+ it("throws on create when target missing", async () => {
+ mockDb.target.findMany.mockResolvedValue([]);
await expect(
createOperationWithValidations({
db: mockDb as unknown as PrismaClient,
user,
- input: { name: "Op", description: "D", crownJewelIds: ["cj1"] },
+ input: { name: "Op", description: "D", targetIds: ["target1"] },
})
- ).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "One or more crown jewels not found" }));
+ ).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "One or more targets not found" }));
});
it("throws on update when operation not found", async () => {
diff --git a/src/test/operations-access.test.ts b/src/test/operations-access.test.ts
index a88970d..52c017d 100644
--- a/src/test/operations-access.test.ts
+++ b/src/test/operations-access.test.ts
@@ -50,7 +50,7 @@ describe("operations access filters", () => {
accessGroups: [ { group: { members: [] } } ],
techniques: [],
threatActor: null,
- crownJewels: [],
+ targets: [],
createdBy: { id: "other", name: "", email: "" },
});
const caller = operationsRouter.createCaller(ctx("VIEWER"));
diff --git a/src/test/operations-create-update-delete.test.ts b/src/test/operations-create-update-delete.test.ts
index 5ddd244..03961e0 100644
--- a/src/test/operations-create-update-delete.test.ts
+++ b/src/test/operations-create-update-delete.test.ts
@@ -9,7 +9,7 @@ vi.mock("@/server/db", () => ({
operation: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn() },
threatActor: { findUnique: vi.fn() },
tag: { findMany: vi.fn() },
- crownJewel: { findMany: vi.fn() },
+ target: { findMany: vi.fn() },
userGroup: { count: vi.fn() },
},
}));
@@ -22,7 +22,7 @@ const createOperationData = {
description: "Test operation description",
threatActorId: "threat-actor-1",
tagIds: ["tag-1", "tag-2"],
- crownJewelIds: ["crown-jewel-1"],
+ targetIds: ["target-1"],
};
const mockOperation = {
@@ -41,7 +41,7 @@ const mockOperation = {
createdBy: { id: "user-1", name: "Test User", email: "test@example.com" },
threatActor: { id: "threat-actor-1", name: "APT29" },
tags: [{ id: "tag-1", name: "Purple Team" }],
- crownJewels: [{ id: "crown-jewel-1", name: "Customer Database" }],
+ targets: [{ id: "target-1", name: "Customer Database", isCrownJewel: true }],
techniques: [],
};
@@ -56,7 +56,7 @@ describe("Operations Router — create/update/delete", () => {
const caller = operationsRouter.createCaller(ctx);
mockDb.threatActor.findUnique.mockResolvedValue({ id: "threat-actor-1", name: "APT29" });
mockDb.tag.findMany.mockResolvedValue([{ id: "tag-1", name: "Purple Team" }, { id: "tag-2", name: "Stealth" }]);
- mockDb.crownJewel.findMany.mockResolvedValue([{ id: "crown-jewel-1", name: "Customer Database" }]);
+ mockDb.target.findMany.mockResolvedValue([{ id: "target-1", name: "Customer Database", isCrownJewel: true }]);
mockDb.operation.create.mockResolvedValue(mockOperation);
const res = await caller.create(createOperationData);
expect(res).toEqual(mockOperation);
diff --git a/src/test/operations-read.test.ts b/src/test/operations-read.test.ts
index 5545f8b..0f6e2c9 100644
--- a/src/test/operations-read.test.ts
+++ b/src/test/operations-read.test.ts
@@ -39,7 +39,7 @@ describe("Operations Router — read", () => {
it("gets by id", async () => {
const ctx = createTestContext(mockDb, "OPERATOR");
const caller = operationsRouter.createCaller(ctx);
- mockDb.operation.findUnique.mockResolvedValue({ ...mockOp, tags: [], crownJewels: [], techniques: [] });
+ mockDb.operation.findUnique.mockResolvedValue({ ...mockOp, tags: [], targets: [], techniques: [] });
const result = await caller.getById({ id: 1 });
expect(result.id).toBe(1);
expect(result.name).toBe("Test Operation");
diff --git a/src/test/server/services/usageService.test.ts b/src/test/server/services/usageService.test.ts
index 1f9e8c3..e441b8a 100644
--- a/src/test/server/services/usageService.test.ts
+++ b/src/test/server/services/usageService.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import {
- getCrownJewelUsageCount,
+ getTargetUsageCount,
getTagUsageCount,
getThreatActorUsageCount,
getToolCategoryUsageCount,
@@ -17,12 +17,12 @@ describe("usageService", () => {
expect(count).toHaveBeenCalledWith({ where: { threatActorId: "actor-1" } });
});
- it("counts crown jewel usage via operations", async () => {
+ it("counts target usage via operations", async () => {
const count = vi.fn().mockResolvedValue(2);
const db = { operation: { count } } as unknown as PrismaClient;
- await expect(getCrownJewelUsageCount(db, "cj-1")).resolves.toBe(2);
- expect(count).toHaveBeenCalledWith({ where: { crownJewels: { some: { id: "cj-1" } } } });
+ await expect(getTargetUsageCount(db, "target-1")).resolves.toBe(2);
+ expect(count).toHaveBeenCalledWith({ where: { targets: { some: { id: "target-1" } } } });
});
it("counts tag usage via operations", async () => {
diff --git a/src/test/taxonomy-service.test.ts b/src/test/taxonomy-service.test.ts
index c8d9d40..8de18e3 100644
--- a/src/test/taxonomy-service.test.ts
+++ b/src/test/taxonomy-service.test.ts
@@ -1,10 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
-import {
- deleteTag,
- createTool,
- deleteCrownJewel,
-} from "@/server/services/taxonomyService";
+import { deleteTag, createTool, deleteTarget } from "@/server/services/taxonomyService";
import type { ToolType, PrismaClient } from "@prisma/client";
const mockDb = {
@@ -12,7 +8,7 @@ const mockDb = {
toolCategory: { findFirst: vi.fn() },
tool: { create: vi.fn() },
outcome: { count: vi.fn() },
- crownJewel: { delete: vi.fn() },
+ target: { delete: vi.fn() },
};
describe("taxonomyService", () => {
@@ -34,10 +30,10 @@ describe("taxonomyService", () => {
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "Invalid category for tool type" }));
});
- it("blocks crown jewel deletion when in use", async () => {
+ it("blocks target deletion when in use", async () => {
mockDb.operation.count.mockResolvedValue(1);
- await expect(deleteCrownJewel(mockDb as unknown as PrismaClient, "cj1")).rejects.toThrow(
- new TRPCError({ code: "BAD_REQUEST", message: "Cannot delete crown jewel: 1 operation(s) are using this crown jewel" })
+ await expect(deleteTarget(mockDb as unknown as PrismaClient, "target1")).rejects.toThrow(
+ new TRPCError({ code: "BAD_REQUEST", message: "Cannot delete target: 1 operation(s) are using this target" })
);
});
});
diff --git a/src/test/taxonomy.test.ts b/src/test/taxonomy.test.ts
index 040373d..1d8e0f6 100644
--- a/src/test/taxonomy.test.ts
+++ b/src/test/taxonomy.test.ts
@@ -12,7 +12,7 @@ vi.mock("@/server/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
- crownJewel: {
+ target: {
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -219,25 +219,26 @@ describe("Taxonomy Router", () => {
});
});
- describe("Crown Jewels", () => {
+ describe("Targets", () => {
describe("create", () => {
- it("should create crown jewel", async () => {
- const newCrownJewel = {
+ it("should create target", async () => {
+ const newTarget = {
name: "Customer Database",
description: "Primary customer data store",
+ isCrownJewel: true,
};
- const mockCreatedCrownJewel = { id: "cj-1", ...newCrownJewel };
- mockDb.crownJewel.create.mockResolvedValue(mockCreatedCrownJewel);
+ const mockCreatedTarget = { id: "t-1", ...newTarget };
+ mockDb.target.create.mockResolvedValue(mockCreatedTarget);
const ctx = createMockContext(UserRole.ADMIN);
const caller = taxonomyRouter.createCaller(ctx);
- const result = await caller.crownJewels.create(newCrownJewel);
+ const result = await caller.targets.create(newTarget);
- expect(result).toEqual(mockCreatedCrownJewel);
- expect(mockDb.crownJewel.create).toHaveBeenCalledWith({
- data: newCrownJewel,
+ expect(result).toEqual(mockCreatedTarget);
+ expect(mockDb.target.create).toHaveBeenCalledWith({
+ data: newTarget,
});
});
@@ -408,7 +409,7 @@ describe("Taxonomy Router", () => {
).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Admin access required" }));
await expect(
- caller.crownJewels.create({ name: "Test", description: "Test" })
+ caller.targets.create({ name: "Test", description: "Test", isCrownJewel: false })
).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Admin access required" }));
await expect(
diff --git a/src/test/techniques-create.test.ts b/src/test/techniques-create.test.ts
index b781891..7e80e2e 100644
--- a/src/test/techniques-create.test.ts
+++ b/src/test/techniques-create.test.ts
@@ -10,6 +10,7 @@ vi.mock("@/server/db", () => ({
mitreTechnique: { findUnique: vi.fn() },
mitreSubTechnique: { findUnique: vi.fn() },
tool: { findMany: vi.fn() },
+ target: { findMany: vi.fn() },
},
}));
@@ -25,15 +26,15 @@ const createTechniqueData = {
endTime: new Date("2024-01-01T11:00:00Z"),
sourceIp: "192.168.1.100",
targetSystem: "workstation-01",
- crownJewelTargeted: true,
- crownJewelCompromised: false,
toolIds: ["tool-1"],
+ targetEngagements: [{ targetId: "target-1", status: "succeeded" }],
};
function mockCreateTechniqueDependencies() {
mockDb.mitreTechnique.findUnique.mockResolvedValue({ id: "T1566", name: "Phishing" });
mockDb.mitreSubTechnique.findUnique.mockResolvedValue({ id: "T1566.001", name: "Sub", techniqueId: "T1566" });
mockDb.tool.findMany.mockResolvedValue([{ id: "tool-1", name: "Cobalt Strike" }]);
+ mockDb.target.findMany.mockResolvedValue([{ id: "target-1", name: "Target" }]);
mockDb.technique.findFirst.mockResolvedValue(null);
mockDb.technique.create.mockResolvedValue({ id: "technique-1" });
}