frontend: handle both old and new analysis reports

Adds support for versioned analysis reports (and defaults to v1 for
reports with no version).
This commit is contained in:
Will Greenberg
2025-07-14 16:22:53 -07:00
committed by Cooper Quintin
parent e81df18315
commit 7cd8835cab
5 changed files with 294 additions and 163 deletions

View File

@@ -1,91 +0,0 @@
import { describe, it, expect } from 'vitest';
import { EventType, parse_finished_report, Severity } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'LTE SIB 6/7 Downgrade',
description:
'Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.',
},
{
name: 'IMSI Provided',
description: "Tests whether the UE's IMSI was ever provided to the cell",
},
{
name: 'Null Cipher',
description: 'Tests whether the cell suggests using a null cipher (EEA0)',
},
{
name: 'Example Analyzer',
description:
'Always returns true, if you are seeing this you are either a developer or you are about to have problems.',
},
],
},
{
timestamp: '2024-10-08T13:25:43.011689003-07:00',
skipped_message_reasons: [
'DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: "PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1", context: [] }))',
],
analysis: [],
},
{
timestamp: '2024-10-08T13:25:43.480872496-07:00',
skipped_message_reasons: [],
analysis: [
{
timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
null,
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'TMSI was provided to cell',
},
],
},
],
},
];
describe('analysis report parsing', () => {
it('parses the example analysis', () => {
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
expect(report.metadata.analyzers).toEqual([
{
name: 'LTE SIB 6/7 Downgrade',
description:
'Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.',
},
{
name: 'IMSI Provided',
description: "Tests whether the UE's IMSI was ever provided to the cell",
},
{
name: 'Null Cipher',
description: 'Tests whether the cell suggests using a null cipher (EEA0)',
},
{
name: 'Example Analyzer',
description:
'Always returns true, if you are seeing this you are either a developer or you are about to have problems.',
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
expect(report.rows[0].analysis).toHaveLength(0);
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
expect(report.rows[1].analysis).toHaveLength(1);
expect(report.rows[1].analysis[0].events).toHaveLength(1);
const event = report.rows[1].analysis[0].events[0];
if (event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
});
});

View File

@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, EventType, parse_finished_report, Severity } 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: [
{
name: "Analyzer 1",
description: "A first analyzer",
version: 2,
},
{
name: "Analyzer 2",
description: "A second analyzer",
version: 2,
},
],
report_version: 2,
},
{
skipped_message_reason: 'The reason why the message was skipped',
},
{
packet_timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
];
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: 1,
},
{
name: 'Analyzer 2',
description: "A second analyzer",
version: 1,
},
]);
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);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: "A first analyzer",
version: 2,
},
{
name: 'Analyzer 2',
description: "A second analyzer",
version: 2,
},
]);
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'
}
});
});

View File

@@ -13,9 +13,23 @@ export type ReportStatistics = {
num_skipped_packets: number; num_skipped_packets: number;
}; };
export type ReportMetadata = { export class ReportMetadata {
analyzers: AnalyzerMetadata[]; public analyzers: AnalyzerMetadata[];
rayhunter: RayhunterMetadata; public rayhunter: RayhunterMetadata;
public report_version: number;
constructor(ndjson: any) {
this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter;
if (ndjson.report_version === undefined) {
this.report_version = 1;
this.analyzers.forEach(analyzer => {
analyzer.version = 1;
});
} else {
this.report_version = ndjson.report_version;
}
}
}; };
export type RayhunterMetadata = { export type RayhunterMetadata = {
@@ -27,19 +41,27 @@ export type RayhunterMetadata = {
export type AnalyzerMetadata = { export type AnalyzerMetadata = {
name: string; name: string;
description: string; description: string;
version: number;
}; };
export type AnalysisRow = { export type AnalysisRow = SkippedPacket | PacketAnalysis;
timestamp: Date; export enum AnalysisRowType {
skipped_message_reasons: string[]; Skipped,
analysis: PacketAnalysis[]; Analysis,
}; }
export type SkippedPacket = {
type: AnalysisRowType.Skipped;
reason: string;
}
export type PacketAnalysis = { export type PacketAnalysis = {
timestamp: Date; type: AnalysisRowType.Analysis;
packet_timestamp: Date;
events: Event[]; events: Event[];
}; };
export type Event = QualitativeWarning | InformationalEvent;
export type Event = QualitativeWarning | InformationalEvent | null;
export enum EventType { export enum EventType {
Informational, Informational,
Warning, Warning,
@@ -62,25 +84,13 @@ export type InformationalEvent = {
message: string; message: string;
}; };
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport { function get_event(event_json: any): Event {
const metadata: ReportMetadata = report_json[0]; // this can be cast directly if (event_json.event_type.type === 'Informational') {
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
const events: Event[] = analysis_json.events
.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else if (event_json.event_type.type === 'Informational') {
num_informational_logs += 1;
return { return {
type: EventType.Informational, type: EventType.Informational,
message: event_json.message, message: event_json.message,
}; };
} else { } else {
num_warnings += 1;
return { return {
type: EventType.Warning, type: EventType.Warning,
severity: severity:
@@ -92,26 +102,100 @@ export function parse_finished_report(report_json: NewlineDeliminatedJson): Anal
message: event_json.message, message: event_json.message,
}; };
} }
}
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,
}) })
.filter((maybe_event: Event | null) => maybe_event !== null); }
return { for (const analysis_json of row_json.analysis) {
timestamp: analysis_json.timestamp, 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, events,
};
}); });
num_skipped_packets += row_json.skipped_message_reasons.length; }
return { }
timestamp: new Date(row_json.timestamp), return rows;
skipped_message_reasons: row_json.skipped_message_reasons, }
analysis,
}; function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) {
rows.push({
type: AnalysisRowType.Skipped,
reason: row_json.skipped_message_reason,
}); });
} else {
const events: Event[] = row_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(row_json.packet_timestamp),
events,
});
}
}
return rows;
}
function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
for (const row of rows) {
if (row.type === AnalysisRowType.Skipped) {
num_skipped_packets++;
} else {
for (const event of row.events) {
if (event !== null) {
if (event.type === EventType.Informational) {
num_informational_logs++;
} else {
num_warnings++;
}
}
}
}
}
return { return {
statistics: {
num_informational_logs,
num_warnings, num_warnings,
num_informational_logs,
num_skipped_packets, num_skipped_packets,
}, }
}
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));
}
let statistics = get_report_stats(rows);
return {
statistics,
metadata, metadata,
rows, rows,
}; };

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AnalysisStatus } from '$lib/analysisManager.svelte'; import { AnalysisStatus } from '$lib/analysisManager.svelte';
import { EventType } from '$lib/analysis.svelte'; import { AnalysisRowType, EventType } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte'; import type { ManifestEntry } from '$lib/manifest.svelte';
let { let {
entry, entry,
@@ -23,17 +23,7 @@
} else if (typeof entry.analysis_report === 'string') { } else if (typeof entry.analysis_report === 'string') {
return entry.analysis_report; return entry.analysis_report;
} else { } else {
let num_warnings = 0; return `${entry.analysis_report.statistics.num_warnings} warnings`;
for (let row of entry.analysis_report.rows) {
for (let analysis of row.analysis) {
for (let event of analysis.events) {
if (event.type === EventType.Warning) {
num_warnings += 1;
}
}
}
}
return `${num_warnings} warnings`;
} }
} else { } else {
return 'Loading...'; return 'Loading...';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { EventType, type AnalysisReport } from '$lib/analysis.svelte'; import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
let { let {
report, report,
}: { }: {
@@ -11,15 +11,17 @@
dateStyle: 'short', dateStyle: 'short',
}); });
const analyzers = report.metadata.analyzers;
const skipped_messages: Map<string, number> = $derived.by(() => { const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map(); let map = new Map();
for (const row of report.rows) { for (const row of report.rows) {
for (const message of row.skipped_message_reasons) { if (row.type === AnalysisRowType.Skipped) {
let count = map.get(message); let count = map.get(row.reason);
if (count === undefined) { if (count === undefined) {
count = 0; count = 0;
} }
map.set(message, count + 1); map.set(row.reason, count + 1);
} }
} }
return map; return map;
@@ -35,15 +37,17 @@
<thead class="p-2"> <thead class="p-2">
<tr class="bg-gray-300"> <tr class="bg-gray-300">
<th class="p-2">Timestamp</th> <th class="p-2">Timestamp</th>
<th class="p-2">Heuristic</th>
<th class="p-2">Warning</th> <th class="p-2">Warning</th>
<th class="p-2">Severity</th> <th class="p-2">Severity</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each report.rows as row} {#each report.rows as row}
{#each row.analysis as analysis} {#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(analysis.timestamp)} {@const parsed_date = new Date(row.packet_timestamp)}
{#each analysis.events.filter((e) => e !== null) as event} {#each row.events.filter((e) => e !== null) as event, i}
{@const analyzer = analyzers[i]}
<tr class="even:bg-gray-200 odd:bg-white"> <tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning} {#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][event.severity]} {@const severity = ['Low', 'Medium', 'High'][event.severity]}
@@ -53,16 +57,18 @@
'bg-red-600', 'bg-red-600',
][event.severity]} ][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td> <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.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td> <td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational} {:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td> <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.message}</td>
<td class="p-2">Info</td> <td class="p-2">Info</td>
{/if} {/if}
</tr> </tr>
{/each} {/each}
{/each} {/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>