4dea19cae9
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Admins now create users with a name + email (no password) and role; a Send Email action (per-user, also reusable for resets) issues a one-time token and POSTs it to an admin-configurable webhook URL — intended for a Power Automate flow that delivers the actual email. The old admin-sets-the-password flow is kept server-side (LegacyUserCreateWithPassword) but no longer wired into the UI. Every user-facing surface (top bar, sidebar, user management, audit log, assignee pickers, dispute notifications) now shows full_name instead of username, falling back to username when unset. Also fixes long attack_procedure/expected_detection/suggested_text text overflowing its container in the template review panels and Red/Blue team fields (missing break-words on whitespace-pre-wrap blocks).
501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
import client from "./client";
|
|
import type {
|
|
Test,
|
|
TestResult,
|
|
ContainmentResult,
|
|
AttackSuccessResult,
|
|
DataClassification,
|
|
TestState,
|
|
TestTimelineEntry,
|
|
} from "../types/models";
|
|
|
|
// ── Payloads ───────────────────────────────────────────────────────
|
|
|
|
export interface TestCreatePayload {
|
|
technique_id: string;
|
|
name: string;
|
|
description?: string;
|
|
platform?: string;
|
|
procedure_text?: string;
|
|
tool_used?: string;
|
|
}
|
|
|
|
export interface TestUpdatePayload {
|
|
name?: string;
|
|
description?: string;
|
|
platform?: string;
|
|
procedure_text?: string;
|
|
tool_used?: string;
|
|
result?: TestResult;
|
|
}
|
|
|
|
export interface RedUpdatePayload {
|
|
name?: string;
|
|
description?: string;
|
|
procedure_text?: string;
|
|
tool_used?: string;
|
|
attack_success?: AttackSuccessResult;
|
|
red_summary?: string;
|
|
execution_start_time?: string;
|
|
execution_end_time?: string;
|
|
}
|
|
|
|
export interface BlueUpdatePayload {
|
|
detection_result?: TestResult;
|
|
containment_result?: ContainmentResult;
|
|
detection_time?: string;
|
|
containment_time?: string;
|
|
blue_summary?: string;
|
|
detect_procedure?: string;
|
|
}
|
|
|
|
export interface RedValidationPayload {
|
|
red_validation_status: "approved" | "rejected";
|
|
red_validation_notes?: string;
|
|
}
|
|
|
|
export interface BlueValidationPayload {
|
|
blue_validation_status: "approved" | "rejected";
|
|
blue_validation_notes?: string;
|
|
}
|
|
|
|
/** Legacy payload — kept for backwards compat. */
|
|
export interface TestValidatePayload {
|
|
result: TestResult;
|
|
comments?: string;
|
|
}
|
|
|
|
export interface TestListFilters {
|
|
state?: TestState;
|
|
technique_id?: string;
|
|
/** Free-text filter on technique MITRE ID or name — doesn't require knowing the technique's UUID. */
|
|
technique_search?: string;
|
|
platform?: string;
|
|
created_by?: string;
|
|
pending_validation_side?: "red" | "blue";
|
|
/** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */
|
|
reviewer_id?: string;
|
|
not_in_any_campaign?: boolean;
|
|
attack_success?: AttackSuccessResult;
|
|
detection_result?: TestResult;
|
|
/** ISO date/datetime — filters on whichever lead validated last. */
|
|
validated_from?: string;
|
|
validated_to?: string;
|
|
assigned_to_me?: boolean;
|
|
unassigned_red?: boolean;
|
|
unassigned_blue?: boolean;
|
|
offset?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
function buildTestListParams(filters?: TestListFilters): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
if (filters?.state) params.append("state", filters.state);
|
|
if (filters?.technique_id) params.append("technique_id", filters.technique_id);
|
|
if (filters?.technique_search) params.append("technique_search", filters.technique_search);
|
|
if (filters?.platform) params.append("platform", filters.platform);
|
|
if (filters?.created_by) params.append("created_by", filters.created_by);
|
|
if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side);
|
|
if (filters?.reviewer_id) params.append("reviewer_id", filters.reviewer_id);
|
|
if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true");
|
|
if (filters?.attack_success) params.append("attack_success", filters.attack_success);
|
|
if (filters?.detection_result) params.append("detection_result", filters.detection_result);
|
|
if (filters?.validated_from) params.append("validated_from", filters.validated_from);
|
|
if (filters?.validated_to) params.append("validated_to", filters.validated_to);
|
|
return params;
|
|
}
|
|
|
|
// ── CRUD ───────────────────────────────────────────────────────────
|
|
|
|
/** List tests with optional filters. */
|
|
export async function getTests(filters?: TestListFilters): Promise<Test[]> {
|
|
const params = buildTestListParams(filters);
|
|
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
|
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
|
|
|
const { data } = await client.get<Test[]>(
|
|
`/tests${params.toString() ? `?${params}` : ""}`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** Return the true total of tests matching the given filters (unaffected by pagination limits). */
|
|
export async function getTestsCount(filters?: TestListFilters): Promise<number> {
|
|
const params = buildTestListParams(filters);
|
|
const { data } = await client.get<{ total: number }>(
|
|
`/tests/count${params.toString() ? `?${params}` : ""}`,
|
|
);
|
|
return data.total;
|
|
}
|
|
|
|
/** Create a new test. */
|
|
export async function createTest(payload: TestCreatePayload): Promise<Test> {
|
|
const { data } = await client.post<Test>("/tests", payload);
|
|
return data;
|
|
}
|
|
|
|
/** Create a test from an existing template, with optional field overrides. */
|
|
export async function createTestFromTemplate(
|
|
templateId: string,
|
|
techniqueId: string,
|
|
overrides?: {
|
|
name?: string;
|
|
description?: string;
|
|
platform?: string;
|
|
procedure_text?: string;
|
|
tool_used?: string;
|
|
detect_procedure?: string;
|
|
},
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>("/tests/from-template", {
|
|
template_id: templateId,
|
|
technique_id: techniqueId,
|
|
...overrides,
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/** Get test by ID (with evidences). */
|
|
export async function getTestById(testId: string): Promise<Test> {
|
|
const { data } = await client.get<Test>(`/tests/${testId}`);
|
|
return data;
|
|
}
|
|
|
|
/** Update a test (only draft/rejected). */
|
|
export async function updateTest(
|
|
testId: string,
|
|
payload: TestUpdatePayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.patch<Test>(`/tests/${testId}`, payload);
|
|
return data;
|
|
}
|
|
|
|
/** Update the data classification label for a test (admin only). */
|
|
export async function updateTestClassification(
|
|
testId: string,
|
|
dataClassification: DataClassification,
|
|
): Promise<Test> {
|
|
const { data } = await client.patch<Test>(`/tests/${testId}/classification`, {
|
|
data_classification: dataClassification,
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/** Assign operators or hand off a review to a peer lead (leads + managers only). */
|
|
export async function assignTestOperators(
|
|
testId: string,
|
|
payload: {
|
|
red_tech_assignee?: string | null;
|
|
blue_tech_assignee?: string | null;
|
|
red_reviewer_assignee?: string | null;
|
|
blue_reviewer_assignee?: string | null;
|
|
},
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
|
|
return data;
|
|
}
|
|
|
|
// ── Red Team ───────────────────────────────────────────────────────
|
|
|
|
/** Red Team updates their fields (draft, red_executing). */
|
|
export async function updateTestRed(
|
|
testId: string,
|
|
payload: RedUpdatePayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.patch<Test>(`/tests/${testId}/red`, payload);
|
|
return data;
|
|
}
|
|
|
|
/** Move test from draft → red_executing. */
|
|
export async function startExecution(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/start-execution`);
|
|
return data;
|
|
}
|
|
|
|
/** Red Team finalises — red_executing → blue_evaluating. */
|
|
export async function submitRedEvidence(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/submit-red`);
|
|
return data;
|
|
}
|
|
|
|
// ── Timer Controls ─────────────────────────────────────────────────
|
|
|
|
/** Pause the active phase timer. */
|
|
export async function pauseTimer(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/pause-timer`);
|
|
return data;
|
|
}
|
|
|
|
/** Resume a paused phase timer. */
|
|
export async function resumeTimer(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/resume-timer`);
|
|
return data;
|
|
}
|
|
|
|
// ── Blue Team ──────────────────────────────────────────────────────
|
|
|
|
/** Blue Team updates their fields (blue_evaluating only). */
|
|
export async function updateTestBlue(
|
|
testId: string,
|
|
payload: BlueUpdatePayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.patch<Test>(`/tests/${testId}/blue`, payload);
|
|
return data;
|
|
}
|
|
|
|
/** Blue Team finalises — blue_evaluating → in_review. */
|
|
export async function submitBlueEvidence(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/submit-blue`);
|
|
return data;
|
|
}
|
|
|
|
/** Blue tech picks up the test to start evaluating — sets the Tempo timer start. */
|
|
export async function startBlueWork(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/start-blue-work`);
|
|
return data;
|
|
}
|
|
|
|
// ── Lead Review Gates (red_review / blue_review) ────────────────────
|
|
|
|
export interface RedReviewPayload {
|
|
decision: "approve" | "reopen";
|
|
notes?: string;
|
|
}
|
|
|
|
export interface BlueReviewPayload {
|
|
decision: "approve" | "reopen" | "gap";
|
|
notes?: string;
|
|
system_gaps?: string;
|
|
}
|
|
|
|
/** Assigned Red Lead approves or reopens a test sitting in red_review. */
|
|
export async function reviewAsRedLead(testId: string, payload: RedReviewPayload): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/review-red`, payload);
|
|
return data;
|
|
}
|
|
|
|
/** Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review. */
|
|
export async function reviewAsBlueLead(testId: string, payload: BlueReviewPayload): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/review-blue`, payload);
|
|
return data;
|
|
}
|
|
|
|
// ── Lead Validation ────────────────────────────────────────────────
|
|
|
|
/** Red Lead approves/rejects the red side. */
|
|
export async function validateAsRedLead(
|
|
testId: string,
|
|
payload: RedValidationPayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(
|
|
`/tests/${testId}/validate-red`,
|
|
payload,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** Blue Lead approves/rejects the blue side. */
|
|
export async function validateAsBlueLead(
|
|
testId: string,
|
|
payload: BlueValidationPayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(
|
|
`/tests/${testId}/validate-blue`,
|
|
payload,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// ── Dispute resolution ───────────────────────────────────────────────
|
|
|
|
export interface ResolveDisputePayload {
|
|
target_team: "red" | "blue";
|
|
notes?: string;
|
|
}
|
|
|
|
/** The approving lead flips to reject, choosing which team must redo the work. */
|
|
export async function resolveDispute(testId: string, payload: ResolveDisputePayload): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/resolve-dispute`, payload);
|
|
return data;
|
|
}
|
|
|
|
/** Either lead on a disputed test asks a manager to step in and decide. */
|
|
export async function escalateToManager(testId: string): Promise<{ status: string; message: string }> {
|
|
const { data } = await client.post(`/tests/${testId}/escalate-to-manager`);
|
|
return data;
|
|
}
|
|
|
|
export interface ManagerResolveDisputePayload {
|
|
outcome: "validated" | "rejected";
|
|
notes?: string;
|
|
}
|
|
|
|
/** A manager decides the final outcome of a disputed test directly. */
|
|
export async function managerResolveDispute(
|
|
testId: string,
|
|
payload: ManagerResolveDisputePayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/manager-resolve-dispute`, payload);
|
|
return data;
|
|
}
|
|
|
|
// ── Reopen ─────────────────────────────────────────────────────────
|
|
|
|
/** Reopen a rejected test — moves back to draft. */
|
|
export async function reopenTest(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/reopen`);
|
|
return data;
|
|
}
|
|
|
|
// ── Assignment ─────────────────────────────────────────────────────
|
|
|
|
/** Assign red_tech and/or blue_tech operators to a test. Admin/leads only. */
|
|
export async function assignTest(
|
|
testId: string,
|
|
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
|
|
return data;
|
|
}
|
|
|
|
// ── On Hold ────────────────────────────────────────────────────────
|
|
|
|
/** Place a test on hold with a mandatory reason. */
|
|
export async function holdTest(testId: string, reason: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/hold`, { reason });
|
|
return data;
|
|
}
|
|
|
|
/** Resume a test that was placed on hold. */
|
|
export async function resumeTest(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/resume`);
|
|
return data;
|
|
}
|
|
|
|
/** Delete a standalone test that hasn't started yet. Manager only. */
|
|
export async function deleteTest(testId: string): Promise<void> {
|
|
await client.delete(`/tests/${testId}`);
|
|
}
|
|
|
|
// ── Timeline ───────────────────────────────────────────────────────
|
|
|
|
/** Get the audit-log timeline for a test. */
|
|
export async function getTestTimeline(
|
|
testId: string,
|
|
): Promise<TestTimelineEntry[]> {
|
|
const { data } = await client.get<TestTimelineEntry[]>(
|
|
`/tests/${testId}/timeline`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// ── Retest Chain ────────────────────────────────────────────────────
|
|
|
|
export interface RetestChainEntry {
|
|
id: string;
|
|
name: string;
|
|
state: string | null;
|
|
retest_of: string | null;
|
|
retest_count: number;
|
|
result: string | null;
|
|
detection_result: string | null;
|
|
remediation_status: string | null;
|
|
created_at: string | null;
|
|
}
|
|
|
|
/** Get the full retest chain for a test. */
|
|
export async function getRetestChain(testId: string): Promise<RetestChainEntry[]> {
|
|
const { data } = await client.get<RetestChainEntry[]>(`/tests/${testId}/retest-chain`);
|
|
return data;
|
|
}
|
|
|
|
// ── Legacy (kept for backwards compat) ─────────────────────────────
|
|
|
|
/** Validate a test (legacy endpoint). */
|
|
export async function validateTest(
|
|
testId: string,
|
|
payload: TestValidatePayload,
|
|
): Promise<Test> {
|
|
const { data } = await client.post<Test>(
|
|
`/tests/${testId}/validate`,
|
|
payload,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** Reject a test (legacy endpoint). */
|
|
export async function rejectTest(testId: string): Promise<Test> {
|
|
const { data } = await client.post<Test>(`/tests/${testId}/reject`);
|
|
return data;
|
|
}
|
|
|
|
// ── Tempo sync ─────────────────────────────────────────────────────
|
|
|
|
export interface TempoSyncResult {
|
|
worklog_id: string;
|
|
status: "synced" | "already_synced" | "skipped" | "error";
|
|
detail?: string;
|
|
}
|
|
|
|
// ── RT Import ──────────────────────────────────────────────────────
|
|
|
|
export interface RTEvidenceEntry {
|
|
filename: string; // e.g. "screenshot_edr.png"
|
|
data: string; // base64-encoded image (PNG / JPG / GIF / WebP / BMP)
|
|
caption?: string; // optional description
|
|
}
|
|
|
|
export interface RTTechniqueEntry {
|
|
mitre_id: string;
|
|
result: "detected" | "not_detected" | "partially_detected";
|
|
attack_success: AttackSuccessResult;
|
|
platform?: string;
|
|
notes?: string;
|
|
evidence: RTEvidenceEntry[]; // required — at least 1 image
|
|
}
|
|
|
|
export interface RTImportPayload {
|
|
name: string;
|
|
date?: string;
|
|
description?: string;
|
|
operator?: string;
|
|
techniques: RTTechniqueEntry[];
|
|
}
|
|
|
|
export interface RTImportResult {
|
|
created: number;
|
|
skipped: number;
|
|
items: { mitre_id: string; test_name: string; result: string; attack_success: AttackSuccessResult; evidence_attached: number }[];
|
|
warnings: { mitre_id: string; reason: string }[];
|
|
engagement: string;
|
|
}
|
|
|
|
/** Confirm approval in a disputed test and notify the rejecting lead to discuss. */
|
|
export async function requestDiscussion(testId: string): Promise<{
|
|
status: string;
|
|
message: string;
|
|
rejector_username: string;
|
|
rejector_full_name: string | null;
|
|
rejector_email: string | null;
|
|
rejector_role: string;
|
|
}> {
|
|
const { data } = await client.post(`/tests/${testId}/request-discussion`);
|
|
return data;
|
|
}
|
|
|
|
/** Import results from a real Red Team engagement. */
|
|
export async function importRT(payload: RTImportPayload): Promise<RTImportResult> {
|
|
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);
|
|
return data;
|
|
}
|
|
|
|
/** Manually push this test's red team execution worklog to Tempo. */
|
|
export async function syncTestToTempo(
|
|
testId: string,
|
|
): Promise<{ results: TempoSyncResult[] }> {
|
|
const { data } = await client.post<{ results: TempoSyncResult[] }>(
|
|
`/tests/${testId}/sync-tempo`,
|
|
);
|
|
return data;
|
|
}
|