feat(23-01): add USER_AWARENESS verdict + acknowledge_user action mapping
- Add USER_AWARENESS to the Verdict union in campaign-classifier.ts
- mapVerdictToActions('USER_AWARENESS') returns ['acknowledge_user']; not added to DESTRUCTIVE_ACTIONS so requires_approval computes false
- classifyCampaign's simulation branch now assigns verdict = 'USER_AWARENESS' directly instead of falling through to evaluateSpamVsUnwanted
- deriveDefaultParams('acknowledge_user') returns {} (no operator-editable params)
- Widen TriageNoteEvidence.verdict to admit 'USER_AWARENESS' (pure type widen, no formatting change)
- Tests: classifier simulation fixtures now assert USER_AWARENESS/acknowledge_user/requiresApproval=false; new mapVerdictToActions/computeRequiresApproval/deriveDefaultParams cases
This commit is contained in:
parent
7048bf693a
commit
14ed8ca248
5 changed files with 51 additions and 3 deletions
|
|
@ -186,6 +186,10 @@ describe('mapVerdictToActions', () => {
|
|||
expect(actions).toContain('isolate_endpoint');
|
||||
expect(actions).toContain('disable_forwarding_rule');
|
||||
});
|
||||
|
||||
it('maps USER_AWARENESS to exactly acknowledge_user', () => {
|
||||
expect(mapVerdictToActions('USER_AWARENESS', { clicked: 0 })).toEqual(['acknowledge_user']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRequiresApproval', () => {
|
||||
|
|
@ -208,6 +212,10 @@ describe('computeRequiresApproval', () => {
|
|||
expect(computeRequiresApproval(['no_action'])).toBe(false);
|
||||
expect(computeRequiresApproval(['warn_user'])).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for acknowledge_user (USER_AWARENESS is never destructive)', () => {
|
||||
expect(computeRequiresApproval(['acknowledge_user'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -353,6 +361,38 @@ describe('classifyCampaign', () => {
|
|||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['knowbe4 (From match)', knowbe4SimMessage],
|
||||
['breach-secure-now (Return-Path match)', bsnSimMessage],
|
||||
])(
|
||||
'classifies a known simulation sender as USER_AWARENESS with acknowledge_user recommended and requiresApproval false (%s)',
|
||||
async (_label, fixture) => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', fixture)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 1,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).toBe('USER_AWARENESS');
|
||||
expect(result.recommendedActions).toEqual(['acknowledge_user']);
|
||||
expect(result.requiresApproval).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function computeConfidence(evidence: ConfidenceEvidenceFlags): Confidence
|
|||
// D-08: Recommended-actions vocabulary + requires_approval invariant
|
||||
// =============================================================================
|
||||
|
||||
export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT';
|
||||
export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS';
|
||||
|
||||
/** Always force requires_approval:true when recommended (CLASSIFY-02). */
|
||||
export const DESTRUCTIVE_ACTIONS = new Set([
|
||||
|
|
@ -182,6 +182,8 @@ export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence):
|
|||
}
|
||||
return actions;
|
||||
}
|
||||
case 'USER_AWARENESS':
|
||||
return ['acknowledge_user'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,7 +460,7 @@ export async function classifyCampaign(campaignId: string): Promise<ClassifyResu
|
|||
let verdict: Verdict;
|
||||
const reasons: string[] = [];
|
||||
if (isSimulation) {
|
||||
verdict = evaluateSpamVsUnwanted(evidence);
|
||||
verdict = 'USER_AWARENESS';
|
||||
reasons.push(
|
||||
'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ describe('deriveDefaultParams', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns {} for acknowledge_user', () => {
|
||||
expect(deriveDefaultParams('acknowledge_user', filledEvidence)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} for an unknown/future action type', () => {
|
||||
expect(deriveDefaultParams('unknown_future_type', filledEvidence)).toEqual({});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export function deriveDefaultParams(actionType: string, evidence: DefaultParamEv
|
|||
return { deviceId: '' };
|
||||
case 'disable_forwarding_rule':
|
||||
return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
|
||||
case 'acknowledge_user':
|
||||
return {};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface TriageNoteEvidence {
|
|||
reportCount: number;
|
||||
companyName?: string | null;
|
||||
subject?: string | null;
|
||||
verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null;
|
||||
verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS' | null;
|
||||
confidence: number | null;
|
||||
summary: string | null;
|
||||
reasons: string[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue