Expose severity to display

See https://github.com/EFForg/rayhunter/issues/334

Severity levels low, medium, high are now exposed to the UI in form of
dotted, dashed and solid lines. The line on the UI represents the
highest-so-far severity seen.

Originally this was intended to be represented by Yellow/Orange/Red, but
this would mean yet another divergence for colorblind mode. This is
colorblind-friendly by default (I think...)

As part of this, simplify EventType so that it becomes a flat "level"
enum without nested variants.

There is also a new debug endpoint that allows one to overwrite the
display level directly for testing.
This commit is contained in:
Markus Unterwaditzer
2025-08-03 21:01:24 +02:00
committed by Cooper Quintin
parent 6927da49b4
commit 781d11ed72
24 changed files with 443 additions and 292 deletions

View File

@@ -1,43 +1,7 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
},
],
},
{
timestamp: '2024-10-08T13:25:43.011689003-07:00',
skipped_message_reasons: ['The reason why the message was skipped'],
analysis: [],
},
{
timestamp: '2024-10-08T13:25:43.480872496-07:00',
skipped_message_reasons: [],
analysis: [
{
timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
],
},
];
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
@@ -62,7 +26,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
event_type: 'Low',
message: 'Something nasty happened',
},
],
@@ -70,40 +34,6 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
];
describe('analysis report parsing', () => {
it('parses v1 example analysis', () => {
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(1);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 0,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 0,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}
});
it('parses v2 example analysis', () => {
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(2);
@@ -128,11 +58,7 @@ describe('analysis report parsing', () => {
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
expect(event!.event_type).toEqual('Low');
} else {
throw 'wrong row type';
}

View File

@@ -21,17 +21,7 @@ export class ReportMetadata {
constructor(ndjson: any) {
this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter;
if (ndjson.report_version === undefined) {
this.report_version = 1;
// we consider our legacy (unversioned) heuristics to be v0 --
// this'll let us clearly differentiate some known false-positive
// results from the pre-versioned era from v1 heuristics
this.analyzers.forEach((analyzer) => {
analyzer.version = 0;
});
} else {
this.report_version = ndjson.report_version;
}
this.report_version = ndjson.report_version || 2; // Default to v2
}
}
@@ -64,77 +54,22 @@ export type PacketAnalysis = {
events: Event[];
};
export type Event = QualitativeWarning | InformationalEvent | null;
export enum EventType {
Informational,
Warning,
}
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
export type Event = {
event_type: EventType;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
} | null;
function get_event(event_json: any): Event {
if (event_json.event_type.type === 'Informational') {
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
return {
type: EventType.Warning,
severity:
event_json.event_type.severity === 'High'
? Severity.High
: event_json.event_type.severity === 'Medium'
? Severity.Medium
: Severity.Low,
message: event_json.message,
};
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
throw `Invalid/unhandled event type: ${event_json.event_type}`;
}
return event_json;
}
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
for (const reason of row_json.skipped_message_reasons) {
rows.push({
type: AnalysisRowType.Skipped,
reason,
});
}
for (const analysis_json of row_json.analysis) {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(analysis_json.timestamp),
events,
});
}
}
return rows;
}
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
function get_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) {
@@ -170,7 +105,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
} else {
for (const event of row.events) {
if (event !== null) {
if (event.type === EventType.Informational) {
if (event.event_type === 'Informational') {
num_informational_logs++;
} else {
num_warnings++;
@@ -188,12 +123,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata = new ReportMetadata(report_json[0]);
let rows;
if (metadata.report_version === 1) {
rows = get_v1_rows(report_json.slice(1));
} else {
rows = get_v2_rows(report_json.slice(1));
}
const rows = get_rows(report_json.slice(1));
const statistics = get_report_stats(rows);
return {
statistics,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
let {
report,
}: {
@@ -50,30 +50,19 @@
{#each row.events as event, analyzerIndex}
{#if event !== null}
{@const analyzer = analyzers[analyzerIndex]}
{@const event_type_class = {
Informational: '',
Low: 'bg-red-200',
Medium: 'bg-red-400',
High: 'bg-red-600',
}[event.event_type]}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][
event.severity
]}
{@const severity_class = [
'bg-red-200',
'bg-red-400',
'bg-red-600',
][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td
>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center"
>{severity}</td
>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td
>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {event_type_class} text-center"
>{event.event_type}</td
>
</tr>
{/if}
{/each}