From 5acd832929cd675f442a027fdbb83b061027b54d Mon Sep 17 00:00:00 2001 From: nikitita Date: Mon, 19 Oct 2026 17:32:44 +0200 Subject: [PATCH] feat: implement threat actor search, filtering and active filter chips --- .../graphql/resolvers/threatActorResolver.ts | 59 +++-- backend/src/graphql/typeDefs.ts | 201 +++++++++++++---- .../validation/threatActorValidation.ts | 192 +++++++++++++++++ .../src/repositories/threatActorRepository.ts | 204 +++++++++++++----- .../ThreatActorActiveFilters.css | 75 +++++++ .../ThreatActorActiveFilters.tsx | 82 +++++++ .../ThreatActorFilter/ThreatActorFilter.css | 31 ++- .../ThreatActorFilter/ThreatActorFilter.tsx | 102 +++++---- .../ThreatActorFilterDropdown.css | 2 +- .../ThreatActorSearch/ThreatActorSearch.css | 4 +- .../ThreatActorSearch/ThreatActorSearch.tsx | 23 +- .../src/graphql/queries/threatActorQueries.ts | 26 ++- .../src/pages/ThreatActors/ThreatActors.tsx | 95 +++++++- frontend/src/types/ThreatActorTypes.ts | 12 ++ 14 files changed, 952 insertions(+), 156 deletions(-) create mode 100644 backend/src/graphql/validation/threatActorValidation.ts create mode 100644 frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.css create mode 100644 frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.tsx diff --git a/backend/src/graphql/resolvers/threatActorResolver.ts b/backend/src/graphql/resolvers/threatActorResolver.ts index 3286484..19c4268 100644 --- a/backend/src/graphql/resolvers/threatActorResolver.ts +++ b/backend/src/graphql/resolvers/threatActorResolver.ts @@ -5,42 +5,75 @@ * repository-laget og sender resultatet tilbake til klienten. * * Database-logikk skal ikke ligge direkte i resolverne. - * Den håndteres av repositories. + * Den håndteres av repositories. Hovedoppgave er håndtering av + * GraphQl spørringene og validering. * * * GraphQL Defs -> Resolver -> Repository -> Drizzle -> MySQL */ import {getAllThreatActorsGrid, getThreatActorByMitreId, getThreatActorFilterOptions, getThreatActorSoftwares, getThreatActorTechniques} from "../../repositories/threatActorRepository.js"; +import { validateAssociatedArgs, validateMitreId, validateThreatActorGridArgs, type ThreatActorGridArgs } from "../validation/threatActorValidation.js"; + + export const threatActorResolvers = { Query: { - /** Henter ut alle filterOptions */ + + /** + * Henter tilgjengelige filteralternativer fra databasen. + * Krever ingen argumenter eller inputvalidering. + */ threatActorFilterOptions: async (_parent: unknown) => { return getThreatActorFilterOptions(); }, + /** - * Henter alle ThreatActors for Grid i front end. + * Henter en paginert liste med Threat Actors. + * + * Validerer søk, filtrering, sortering og pagination + * før argumentene sendes videre til repositoryet. */ - allThreatActorsGrid: async (_parent: unknown, args: {limit: number; offset: number}) => { - return getAllThreatActorsGrid(args.limit, args.offset); + allThreatActorsGrid: async (_parent: unknown, args: ThreatActorGridArgs) => { + + const validatedArgs = validateThreatActorGridArgs(args) + + return getAllThreatActorsGrid(validatedArgs); }, /** - * Henter én Threat Actor med tilhørende detaljer, - * aliaser, sektorer, teknikker og programvare. + * Henter detaljert informasjon om én Threat Actor. + * + * Validerer MITRE-ID før oppslag i databasen. */ getThreatActorByMitreId: async (_parent: unknown, args: {mitreId: string}) => { - return getThreatActorByMitreId(args.mitreId); + + const mitreId = validateMitreId(args.mitreId) + + return getThreatActorByMitreId(mitreId); }, + /** + * Henter paginerte Techniques tilknyttet en Threat Actor. + * + * Validerer MITRE-ID, limit og offset. + */ getThreatActorTechniques: async (_parent: unknown, args: {mitreId: string; limit: number; offset: number}) => { - return getThreatActorTechniques(args.mitreId, args.limit, args.offset); - }, + + const validatedArgs = validateAssociatedArgs(args); - getThreatActorSoftwares: async (_parent: unknown, args: {mitreId: string; limit: number; offset: number}) => { - return getThreatActorSoftwares(args.mitreId, args.limit, args.offset); + return getThreatActorTechniques(validatedArgs.mitreId, validatedArgs.limit, validatedArgs.offset +); }, + /** + * Henter paginert Software tilknyttet en Threat Actor. + * + * Gjenbruker samme argumentvalidering som Techniques. + */ + getThreatActorSoftwares: async (_parent: unknown, args: {mitreId: string; limit: number; offset: number}) => { + + const validatedArgs = validateAssociatedArgs(args); - + return getThreatActorSoftwares(validatedArgs.mitreId, validatedArgs.limit, validatedArgs.offset); + }, }, }; \ No newline at end of file diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index a55b390..10c99a3 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -1,12 +1,52 @@ + /** - * GraphQL schema: defines the types and operations available to the frontend. + * GraphQL Schema (SDL) + * + * Definerer API-kontrakten mellom frontend og backend: + * - Hvilke datatyper som finnes + * - Hvilke argumenter klienten kan sende + * - Hvilke data hver query returnerer + * + * GraphQL bruker: + * - String, Int og Boolean som grunnleggende datatyper + * - ! for å markere en verdi som ikke kan være null + * - [Type!]! for en ikke-null liste med ikke-null elementer + * + * Inputvalidering håndteres i validation-laget. + * Databaseoperasjoner håndteres i repository-laget. */ + export const typeDefs = `#graphql + + # ================================================== + # SHARED TYPES + # ================================================== + + """Representerer en tilknyttet entitet med MITRE-ID og navn.""" + type AssociatedItem { + mitreId: String! + name: String! + } + + """Paginert resultat for tilknyttede entiteter.""" + type AssociatedItemsPage { + items: [AssociatedItem!]! + totalCount: Int! + } + + + # ================================================== + # THREAT ACTORS + # ================================================== + + """Ett tilgjengelig filteralternativ med antall treff.""" type ThreatActorFilterOption { value: String! label: String! count: Int! } + + """Tilgjengelige filterverdier hentet fra databasen.""" type ThreatActorFilterOptions { countries: [ThreatActorFilterOption!]! regions: [ThreatActorFilterOption!]! @@ -15,6 +55,18 @@ export const typeDefs = `#graphql statuses: [ThreatActorFilterOption!]! danger_levels: [ThreatActorFilterOption!]! } + + """Valgfrie filtre for Threat Actor-katalogen.""" + input ThreatActorFilterInput { + country: String + region: String + motivation: String + sector: String + status: String + dangerLevel: String + } + + """Data som trengs for å vise ett Threat Actor-kort.""" type ThreatActorGrid { name: String! mitreId: String! @@ -24,21 +76,13 @@ export const typeDefs = `#graphql motivation: String! } + """Paginert resultat fra Threat Actor-katalogen.""" type ThreatActorGridInfo { items: [ThreatActorGrid!]! totalCount: Int! } - type AssociatedItem { - mitreId: String! - name: String! - } - - type AssociatedItemsPage { - items: [AssociatedItem!]! - totalCount: Int! - } - + """Detaljert informasjon om én Threat Actor.""" type ThreatActorDetailed { threatId: String! mitreId: String! @@ -57,15 +101,21 @@ export const typeDefs = `#graphql sectors: [String!]! } + + # ================================================== + # TECHNIQUES + # ================================================== + + """Detaljert informasjon om en angrepsteknikk.""" type Technique { mitreId: String! name: String! description: String! - tactics: [String!]! platforms: [String!]! } - + + """Data som trengs for å vise ett Technique-kort.""" type TechniqueGrid { mitreId: String! name: String! @@ -73,16 +123,48 @@ export const typeDefs = `#graphql platform: String! } + """Paginert resultat fra Technique-katalogen.""" type TechniqueGridResult { items: [TechniqueGrid!]! totalCount: Int! } - + """En Threat Actor representert ved navn.""" type ThreatActorItem { name: String! } + + # ================================================== + # SOFTWARE + # ================================================== + + """En entitet tilknyttet programvare.""" + type RelatedSoftwareEntity { + id: String! + mitreId: String! + name: String! + } + + """Programvare med tilhørende relasjoner.""" + type Software { + id: String! + mitreId: String! + name: String! + description: String! + type: String! + aliases: [String!]! + platforms: [String!]! + associatedTechniques: [RelatedSoftwareEntity!]! + usedByThreatActors: [RelatedSoftwareEntity!]! + } + + + # ================================================== + # TACTICS + # ================================================== + + """Data som trengs for å vise ett Tactic-kort.""" type TacticGrid { mitreId: String! name: String! @@ -90,6 +172,7 @@ export const typeDefs = `#graphql techniqueCount: Int! } + """Detaljert informasjon om én Tactic.""" type TacticDetailed { tacticId: String! mitreId: String! @@ -99,37 +182,57 @@ export const typeDefs = `#graphql xMitreDeprecated: Boolean! } + """Paginert liste med Techniques.""" type TechniquePage { items: [Technique!]! totalCount: Int! } - type RelatedSoftwareEntity { - id: String! - mitreId: String! - name: String! - } - type Software { - id: String! - mitreId: String! - name: String! - description: String! - type: String! - aliases: [String!]! - platforms: [String!]! - associatedTechniques: [RelatedSoftwareEntity!]! - usedByThreatActors: [RelatedSoftwareEntity!]! - } + # ================================================== + # QUERIES + # ================================================== type Query { - allThreatActorsGrid(limit: Int!, offset: Int!): ThreatActorGridInfo! - getThreatActorByMitreId(mitreId: String!): ThreatActorDetailed - getThreatActorTechniques(mitreId: String!, limit: Int!, offset: Int!): AssociatedItemsPage! - getThreatActorSoftwares(mitreId: String!, limit: Int!, offset: Int!): AssociatedItemsPage! + + # ---------------- THREAT ACTORS ---------------- + + """ + Henter en paginert liste med Threat Actors. + Støtter søk, filtrering og sortering. + """ + allThreatActorsGrid( + limit: Int! + offset: Int! + search: String + filters: ThreatActorFilterInput + sortBy: String + ): ThreatActorGridInfo! + + """Henter detaljert informasjon om én Threat Actor.""" + getThreatActorByMitreId( + mitreId: String! + ): ThreatActorDetailed + + """Henter paginerte Techniques tilknyttet en Threat Actor.""" + getThreatActorTechniques( + mitreId: String! + limit: Int! + offset: Int! + ): AssociatedItemsPage! + + """Henter paginert Software tilknyttet en Threat Actor.""" + getThreatActorSoftwares( + mitreId: String! + limit: Int! + offset: Int! + ): AssociatedItemsPage! + + """Henter tilgjengelige filteralternativer.""" threatActorFilterOptions: ThreatActorFilterOptions! - ######################################## Techinques vvvv + + # ---------------- TECHNIQUES ---------------- # Henter alle Techniques allTechniques( @@ -152,11 +255,33 @@ export const typeDefs = `#graphql mitreId: String! ): [Technique!]! + + # ---------------- SOFTWARE ---------------- + + """Henter all Software.""" software: [Software!]! - softwareByMitreId(mitreId: String!): Software + """Henter én Software basert på MITRE-ID.""" + softwareByMitreId( + mitreId: String! + ): Software + + + # ---------------- TACTICS ---------------- + + """Henter alle Tactics.""" allTactics: [TacticGrid!]! - getTacticByMitreId(mitreId: String!): TacticDetailed - getTacticTechniques(mitreId: String!, limit: Int!, offset: Int!): TechniquePage! + + """Henter én Tactic basert på MITRE-ID.""" + getTacticByMitreId( + mitreId: String! + ): TacticDetailed + + """Henter paginerte Techniques tilknyttet en Tactic.""" + getTacticTechniques( + mitreId: String! + limit: Int! + offset: Int! + ): TechniquePage! } `; diff --git a/backend/src/graphql/validation/threatActorValidation.ts b/backend/src/graphql/validation/threatActorValidation.ts new file mode 100644 index 0000000..726ffef --- /dev/null +++ b/backend/src/graphql/validation/threatActorValidation.ts @@ -0,0 +1,192 @@ +/** + * Ansvarlig for validering av argumenter + * som mottas gjennom Threat Actor sine GraphQL-queries. + * + * Kontrollerer blant annet: + * - Datatyper, tekstlengde og kontrolltegn + * - Pagination (limit og offset) + * - Tillatte filter- og sorteringsverdier + * - MITRE-ID-format + * + * Gyldig input returneres i normalisert form. + * Ugyldig input kaster en GraphQLError med BAD_USER_INPUT. + * + * Resolver -> Validation -> Repository -> Drizzle -> MySQL + */ + +import { GraphQLError } from "graphql"; + +/** Argumenter for søk, filtrering, sortering og paginering av Threat Actors. */ +export type ThreatActorGridArgs = { + limit: number; + offset: number; + search?: string; + filters?: { + country?: string; + region?: string; + motivation?: string; + sector?: string; + status?: string; + dangerLevel?: string; + }; + sortBy?: string; +}; + +/** Argumenter for henting av tilknyttede Techniques og Softwares. */ +export type ThreatActorAssociatedArgs = { + mitreId: string; + limit: number; + offset: number; +}; + +/** + * Avviser ugyldig input med en GraphQL-feil. + * + * never betyr at funksjonen aldri returnerer normalt, + * fordi den alltid kaster en exception. + */ +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: "BAD_USER_INPUT" } + }); +} + +/** + * Validerer og normaliserer tekstinput. + * + * - Manglende verdi returnerer en tom streng. + * - Avviser andre datatyper enn string. + * - Fjerner mellomrom foran og bak. + * - Kontrollerer maksimal lengde og ugyldige kontrolltegn. + */ +function validateText(value: unknown, field: string, maxLength = 100): string { + if (value == null) return ""; + + if (typeof value !== "string") { + return invalidInput(`${field} must be a string`); + } + + const text = value.trim(); + + if (text.length > maxLength) { + return invalidInput(`${field} exceeds ${maxLength} characters`); + } + + if (/\p{Cc}/u.test(text)) { + return invalidInput(`${field} contains invalid control characters`); + } + + return text; +} + +/** + * Validerer pagination: + * - limit: heltall mellom 1 og 50 + * - offset: ikke-negativt heltall + * + * Returnerer void ved gyldig input, ellers kastes en feil. + */ +function validatePagination(limit: number, offset: number): void { + if (!Number.isInteger(limit) || limit < 1 || limit > 50) { + invalidInput("Limit must be between 1 and 50"); + } + + if (!Number.isInteger(offset) || offset < 0) { + invalidInput("Offset must be a non-negative integer"); + } +} + +/** + * Validerer at en verdi finnes blant tillatte alternativer. + * + * Brukes til sortering og enum-baserte filtre. + * Tom streng tillates og representerer et manglende valg. + */ +function validateOption(value: unknown,field: string, allowed: readonly string[]): string { + const text = validateText(value, field); + + if (text !== "" && !allowed.includes(text)) { + invalidInput(`Invalid ${field}: ${text}`); + } + + return text; +} + +/** + * Validerer en Threat Actor sin MITRE-ID. + * + * Forventer formatet G etterfulgt av fire sifre, + * eksempelvis G0001. + * + * Kontrollerer format, ikke om ID-en finnes i databasen. + */ + +export function validateMitreId(input: unknown): string { + const mitreId = validateText(input, "mitreId", 20); + + // Threat Actor-ID må ha formatet G + fire sifre + if (!/^G\d{4}$/.test(mitreId)) { + invalidInput("MITRE ID must have the format G0001"); + } + + return mitreId; +} + +/** + * Validerer argumenter for henting av tilknyttede + * Techniques og Softwares. + * + * Gjenbruker validering av MITRE-ID og pagination. + * Returnerer argumentene med normalisert MITRE-ID. + */ +export function validateAssociatedArgs(args: ThreatActorAssociatedArgs): ThreatActorAssociatedArgs { + + // Valider pagination + validatePagination(args.limit, args.offset); + + // Valider MITRE-ID + const validMitreId = validateMitreId(args.mitreId); + + return { + mitreId: validMitreId, + limit: args.limit, + offset: args.offset + }; +} + +/** + * Validerer argumentene til allThreatActorsGrid. + * + * Kontrollerer pagination, search, sorting og filters. + * Returnerer et nytt objekt med normaliserte argumenter + * som resolveren sender videre til repositoryet. + */ +export function validateThreatActorGridArgs(args: ThreatActorGridArgs): ThreatActorGridArgs { + const { limit, offset, search, sortBy, filters } = args; + + // 1. Valider pagination + validatePagination(limit, offset); + + // 2. Valider og normaliser search og sorting + const validSearch = validateText(search, "search"); + const validSort = validateOption(sortBy, "sortBy", ["name-asc", "name-desc", "mitre-asc", "mitre-desc"]) || "name-asc"; + + // 3. Valider alle filters + const validFilters = { + country: validateText(filters?.country, "country", 150), + region: validateText(filters?.region, "region", 150), + motivation: validateText(filters?.motivation, "motivation", 150), + sector: validateText(filters?.sector, "sector", 150), + status: validateOption(filters?.status, "status", ["ACTIVE", "INACTIVE", "UNKNOWN"]), + dangerLevel: validateOption(filters?.dangerLevel, "dangerLevel", ["LOW", "MODERATE", "HIGH", "CRITICAL"]) + }; + + // Returner validerte argumenter til resolveren + return { + limit, + offset, + search: validSearch, + sortBy: validSort, + filters: validFilters + }; +} \ No newline at end of file diff --git a/backend/src/repositories/threatActorRepository.ts b/backend/src/repositories/threatActorRepository.ts index 06c0642..452625c 100644 --- a/backend/src/repositories/threatActorRepository.ts +++ b/backend/src/repositories/threatActorRepository.ts @@ -1,49 +1,109 @@ /** - * Repository-laget samler databaseoperasjoner relatert til ThreatActor. + * Ansvarlig for databaseoperasjoner relatert til Threat Actors. + * Bruker Drizzle ORM til å bygge og utføre SQL-spørringer mot MySQL. * - * Hensikten er å skille database-logikken fra resten av backenden: - * resolvers skal ikke skrive Drizzle-spørringer direkte, men heller - * kalle funksjoner fra dette repositoryet. - * - * Dermed får vi en tydelig ansvarsfordeling: - * - * GraphQL resolver -> Repository -> Drizzle -> MySQL - * - * Dette gjør koden lettere å lese, teste og endre senere. - * - * - * !!!ALTSÅ HER SKRIVER VI KODEN SOM FAKTISK HENTER UT DATA FRA DB VIA DRIZZLE!!!! + * Hovedansvar: + * - Hente Threat Actors til katalogen og detaljsidene + * - Utføre søk, filtrering, sortering og paginering + * - Hente tilknyttede Techniques, Softwares og aliaser + * - Hente tilgjengelige filteralternativer */ - -import { count, eq, sql } from "drizzle-orm"; - import { db } from "../db/connection.js"; +import {and,asc,count,desc,eq,exists,or,sql,type SQL,} from "drizzle-orm"; import { software, technique, threatActor, threatActorAlias, threatActorSector, threatActorSoftware, threatActorTechnique } from "../db/schema.js"; +import type { ThreatActorGridArgs } from "../graphql/validation/threatActorValidation.js"; + /** - * Henter alle ThreatActors som trengs i frontend gridet. - * - * Bruker en SQL subquery for å hente det alfabetisk først - * aliaset til hver Actor underveis i Drizzle Query. Sikrer at vi - * får minst 1 alias med (LIMIT 1 + COALESCE()) - * - * [COALESCE() erstater en manglende verdi med en forhåndsdefinert verdi - * --> COALESCE(mulig NULL, "Null verdi")] - * - * Fordeller for skalerbarhet: - * - Henter ut alle data med én SQL spørring og unngår N+1 problemmet. - * Dvs slipper å hente ut først alle ThreatActors (1 query) og deretter hente - * ut én alias per ThreatActor (190 queries). Totalt 191 queries = uoptimalt. - * Nå har vi én query som gjør alt. Bruker sql.identifier for å bruke thread_id fra - * ytre querien + * 1.Henter en paginert liste med Threat Actors til katalogen. + * Støtter: + * - Søk etter navn og MITRE-ID + * - Filtrering på land, region, motivasjon, status, + * trusselnivå og sektor + * - Sortering etter navn eller MITRE-ID + * - Paginering med LIMIT og OFFSET * - * For Pagination bruker vi: - * - LIMIT - Hvor mange rader vi maksimal skal hente - * - OFFSET - Hvor mange rader vi hopper over telt fra starten. + * 2.Alias hentes med en korrelert SQL-subquery som velger + * det alfabetisk første aliaset for hver Threat Actor. + * Dette unngår separate databasekall for hver aktør (N+1). * - * Definerer derfor standardverider som gjelder når ingen limit/offset er spesifisert i funksjonskallet + * 3.COALESCE() erstatter NULL-verdier med en standardtekst. */ -export async function getAllThreatActorsGrid(limit: number = 9, offset: number = 0) { +export async function getAllThreatActorsGrid(args: ThreatActorGridArgs) { + const { limit, offset, search, filters, sortBy } = args; + const conditionsFilterSearch: SQL[] = [] + let conditionSort: SQL; + + // Search + /** Search er allerede validert og normalisert av resolver */ + const searchWord = search ?? ""; + + if (searchWord !== ""){ + /** Escaper SQL LIKE-jokertegn slik at %, _ og ! tolkes bokstavelig. */ + const escapedSearch = searchWord.replace(/[!%_]/g, (char) => `!${char}`); + + /** % rundt søkeordet gir treff hvor som helst attributtet */ + const searchPattern = `%${escapedSearch}%`; + + /** name matcher search || mitreid matcher search */ + const searchCondition = or( sql`${threatActor.name} LIKE ${searchPattern} ESCAPE '!'`, sql`${threatActor.mitre_id} LIKE ${searchPattern} ESCAPE '!'`); + + if (searchCondition) { + conditionsFilterSearch.push(searchCondition); + } + } + // Filtrering + /**COALESCE gjør at NULL-verdier kan filtreres som "Unknown".*/ + if (filters?.country){ + conditionsFilterSearch.push(eq(sql`COALESCE(${threatActor.country}, 'Unknown')`, filters.country)) + } + if (filters?.region){ + conditionsFilterSearch.push(eq(sql`COALESCE(${threatActor.region}, 'Unknown')`, filters.region)) + } + if (filters?.motivation){ + conditionsFilterSearch.push(eq(sql`COALESCE(${threatActor.motivation}, 'Unknown')`, filters.motivation)) + } + /**Enum-håndtering med = */ + if (filters?.status) { + conditionsFilterSearch.push(sql`${threatActor.status} = ${filters.status}`); + } + if (filters?.dangerLevel) { + conditionsFilterSearch.push(sql`${threatActor.danger_level} = ${filters.dangerLevel}`); + } + /**Subquery med exists for å hente sector, siden det er 2 tabeller ThreatActor og ThreatActorSector */ + if (filters?.sector){ + conditionsFilterSearch.push( + exists(db + .select({id: threatActorSector.threat_id}).from(threatActorSector) + .where(and(eq(threatActorSector.threat_id, threatActor.threat_id), eq(threatActorSector.sector, filters.sector))) + ) + ) + } + + /**Kombinerer søk og alle aktive filtre med AND.*/ + const conditionFilterSearch = and(...conditionsFilterSearch); + + //Sortering + switch (sortBy) { + case "name-asc": + conditionSort = asc(threatActor.name); + break; + + case "name-desc": + conditionSort = desc(threatActor.name); + break; + + case "mitre-asc": + conditionSort = asc(threatActor.mitre_id); + break; + + case "mitre-desc": + conditionSort = desc(threatActor.mitre_id); + break; + + default: + conditionSort = asc(threatActor.name); + } const actorsSelect = db .select({ name: threatActor.name, @@ -63,7 +123,8 @@ export async function getAllThreatActorsGrid(limit: number = 9, offset: number = motivation: sql `COALESCE(${threatActor.motivation}, 'Unknown')`, }) .from(threatActor) - .orderBy(threatActor.mitre_id) + .where(conditionFilterSearch) //<--- + .orderBy(conditionSort, asc(threatActor.mitre_id)) //<--- .limit(limit) .offset(offset); @@ -74,7 +135,8 @@ export async function getAllThreatActorsGrid(limit: number = 9, offset: number = //Brukes i pagination db.select({ totalCount: count() }) - .from(threatActor), + .from(threatActor) + .where(conditionFilterSearch) ]); return { @@ -83,6 +145,18 @@ export async function getAllThreatActorsGrid(limit: number = 9, offset: number = }; } + +/** + * Henter detaljert informasjon om én Threat Actor. + * + * Slår opp aktøren ved hjelp av MITRE-ID og henter + * deretter alle tilknyttede aliaser og sektorer. + * + * DATE_FORMAT() formaterer datoer som YYYY-MM-DD. + * COALESCE() erstatter manglende verdier med "Unknown". + * + * Returnerer null dersom aktøren ikke finnes. + */ export async function getThreatActorByMitreId(mitre_id : string){ const [actor] = await db .select({ @@ -104,19 +178,17 @@ export async function getThreatActorByMitreId(mitre_id : string){ .where(eq(threatActor.mitre_id, mitre_id)) .limit(1) - //validering av mitre_id + /**Gyldig MITRE-ID betyr ikke nødvendigvis at aktøren finnes.*/ if (!actor){ return null; } + /**Hent alle aliaser og sektorer parallelt. */ const [aliases, sectors] = await Promise.all([ - - //Aliases + /** Aliases*/ db.select({alias: threatActorAlias.alias}).from(threatActorAlias).where(eq(threatActorAlias.threat_id, actor.threatId)), - - //Sectors + /**Sectors */ db.select({sector: threatActorSector.sector}).from(threatActorSector).where(eq(threatActorSector.threat_id, actor.threatId)), - ]); return { @@ -125,10 +197,18 @@ export async function getThreatActorByMitreId(mitre_id : string){ sectors: sectors.map((row) => row.sector), }; } - +/** + * Henter paginerte Techniques tilknyttet en Threat Actor. + * + * Bruker INNER JOIN mellom ThreatActor, koblingstabellen + * ThreatActorTechnique og Technique. + * + * Returnerer både resultatene og totalt antall tilknyttede + * teknikker for korrekt paginering i frontend. + */ export async function getThreatActorTechniques(mitreId: string, limit: number, offset: number) { const [techniques, countResult] = await Promise.all([ - + /**Hent teknikkene på den aktuelle siden. */ db.select({ mitreId: technique.mitre_id, name: technique.name @@ -141,7 +221,7 @@ export async function getThreatActorTechniques(mitreId: string, limit: number, o .limit(limit) .offset(offset), - //Brukes i pagination + /** Tell alle tilknyttede teknikker */ db.select({ totalCount: count() }) .from(threatActorTechnique) .innerJoin(technique, eq(threatActorTechnique.technique_id, technique.technique_id)) @@ -156,9 +236,18 @@ export async function getThreatActorTechniques(mitreId: string, limit: number, o }; } +/** + * Henter paginert Software tilknyttet en Threat Actor. + * + * Bruker INNER JOIN mellom ThreatActor, koblingstabellen + * ThreatActorSoftware og Software. + * + * Returnerer både programvaren på den aktuelle siden + * og totalt antall treff for paginering i frontend. + */ export async function getThreatActorSoftwares(mitreId: string, limit: number, offset: number) { const [softwares, countResult] = await Promise.all([ - + /** Hent programvare på den aktuelle siden. */ db.select({ mitreId: software.mitre_id, name: software.name @@ -171,7 +260,7 @@ export async function getThreatActorSoftwares(mitreId: string, limit: number, of .limit(limit) .offset(offset), - //Brukes i pagination + /** Tell alle tilknyttede programvare. */ db.select({ totalCount: count() }) .from(threatActorSoftware) .innerJoin(software, eq(threatActorSoftware.software_id, software.software_id)) @@ -185,7 +274,17 @@ export async function getThreatActorSoftwares(mitreId: string, limit: number, of }; } -// Henter ut alle filteroptions for filtreringen i ThreatActor +/** + * Henter tilgjengelige filteralternativer for Threat Actor-katalogen. + * + * Bruker GROUP BY og COUNT() for å finne unike verdier + * og hvor mange forekomster hver verdi har i databasen. + * + * Seks databaseforespørsler kjøres parallelt. + * Resultatene mappes til { value, label, count } + * slik at frontend kan bruke dem direkte i dropdown-menyene. + */ + export async function getThreatActorFilterOptions() { /** Henter ut land options */ const countryFilterOptions = db @@ -217,6 +316,7 @@ export async function getThreatActorFilterOptions() { .select({danger: threatActor.danger_level, totalCount: count()}) .from(threatActor) .groupBy(threatActor.danger_level); + //**Kjører queires parallelt, og venter til alle queries er fullført */ const [countries, regions, motivations, sectors, statuses, danger_levels] = await Promise.all([ countryFilterOptions, @@ -227,7 +327,7 @@ export async function getThreatActorFilterOptions() { dangerFilterOptions, ]) - //Mapper det til objekter vi enkelt kan bruke i front + /**Mapper det til objekter vi enkelt kan bruke i frontend */ return { countries: countries.map((row) => ({ value: row.country, @@ -249,7 +349,7 @@ export async function getThreatActorFilterOptions() { label: row.sector, count: row.totalCount })), - //Lager en Label for ACTIVE = A + ctive = Active + /**Lager en Label for ACTIVE = A + ctive = Active */ statuses: statuses.map((row) => ({ value: row.status, label: row.status.charAt(0).toUpperCase() + row.status.slice(1).toLowerCase(), diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.css new file mode 100644 index 0000000..42c33e9 --- /dev/null +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.css @@ -0,0 +1,75 @@ + +/* Rad med aktive søk, filtre og sortering */ +.active-threat-actor-filters { + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + gap: 12px; +} + +/* Container for alle aktive filterchips */ +.active-filter-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +/* Hver enkelt filterchip */ +.active-filter-chip { + display: flex; + align-items: center; + gap: 8px; + + padding: 8px 12px; + + background: var(--surface); + color: #e5f0f0; + border: 1px solid var(--border); + border-radius: 20px; + + font-size: 13px; + font-weight: 500; + cursor: pointer; + + transition: 0.2s ease; +} + +.active-filter-chip svg { + color: var(--accent); +} + +.active-filter-chip:hover { + background: var(--surface-hover); + border-color: var(--accent); +} + +/* Nullstill alle aktive kriterier */ +.active-filter-clear { + background: transparent; + color: var(--text-muted); + border: none; + color: #b8cccc; +font-size: 13px; +font-weight: 500; + + font-size: 12px; + cursor: pointer; + + + + + +} + +.active-filter-clear:hover { + color: var(--accent); +} + +/* Tastaturnavigasjon */ +.active-filter-chip:focus-visible, +.active-filter-clear:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.tsx b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.tsx new file mode 100644 index 0000000..b99d524 --- /dev/null +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters.tsx @@ -0,0 +1,82 @@ + +import { X } from "lucide-react"; +import type { ThreatActorFilterValues } from "../ThreatActorFilter/ThreatActorFilter"; +import "./ThreatActorActiveFilters.css"; + + +// Alle filterfelter og navnene som skal vises +const filterFields = [ + { key: "country", label: "Country" }, + { key: "region", label: "Region" }, + { key: "motivation", label: "Motivation" }, + { key: "sector", label: "Sector" }, + { key: "status", label: "Status" }, + { key: "dangerLevel", label: "Danger Level" }, + { key: "sortBy", label: "Sort" }, +] as const; + +// Brukervennlige navn for sortering +const sortLabels: Record = { + "name-asc": "Name (A - Z)", + "name-desc": "Name (Z - A)", + "mitre-asc": "MITRE ID (Ascending)", + "mitre-desc": "MITRE ID (Descending)", +}; + +type ActiveFiltersProps = { + search: string; + filters: ThreatActorFilterValues; + onRemoveSearch: () => void; + onRemoveFilter: (key: keyof ThreatActorFilterValues) => void; + onClearAll: () => void; +}; + +export default function ThreatActorActiveFilters({search, filters, onRemoveSearch, onRemoveFilter, onClearAll}: ActiveFiltersProps) { + + // Finn filtrene som faktisk er aktive + const activeFilters = filterFields.filter(({ key }) => key === "sortBy" ? filters.sortBy !== "name-asc" : filters[key] !== ""); + + // Ikke vis raden hvis ingen kriterier er aktive + if (!search && activeFilters.length === 0) return null; + + return ( +
+
+ + {/* Search-chip */} + {search && ( + + )} + + {/* Filter- og sorting-chips */} + {activeFilters.map(({ key, label }) => ( + + ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css index 1db74b1..1dc2d99 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css @@ -22,6 +22,7 @@ /* Apply Filters fyller sin grid-celle */ .filters-actions { display: flex; + gap: 8px; width: 100%; min-width: 0; } @@ -32,7 +33,8 @@ justify-content: center; gap: 8px; - width: 100%; + flex: 1; + min-width: 0; min-height: 52px; padding: 10px 12px; @@ -84,3 +86,30 @@ grid-template-columns: minmax(0, 1fr); } } + +.reset-filters-button { + display: flex; + align-items: center; + justify-content: center; + + flex: 0 0 52px; + min-height: 52px; + + background: var(--surface); + color: var(--accent); + + border: 1px solid var(--border); + border-radius: 7px; + + cursor: pointer; + transition: background-color 0.2s ease; +} + +.reset-filters-button:hover { + background: #004447; +} + +.reset-filters-button:focus-visible { + outline: 2px solid var(--text-heading); + outline-offset: 3px; +} \ No newline at end of file diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx index daf171f..1807a5d 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx @@ -6,6 +6,7 @@ import { MapPin, Target, ArrowDownUp, + RotateCcw, } from "lucide-react"; import { useState } from "react"; import FilterDropdown from "./ThreatActorFilterDropdown"; @@ -30,36 +31,55 @@ export type ThreatActorFilterValues = { sortBy: string; }; +export const DEFAULT_FILTERS: ThreatActorFilterValues = { + country: "", + region: "", + motivation: "", + sector: "", + status: "", + dangerLevel: "", + sortBy: "name-asc", +}; + type ThreatActorFiltersProps = { - filterOptions?: ThreatActorFilterOptionsData["threatActorFilterOptions"]; - onApply: (filters: ThreatActorFilterValues) => void; + filterOptions?: ThreatActorFilterOptionsData["threatActorFilterOptions"]; + value: ThreatActorFilterValues; + onChange: (filters: ThreatActorFilterValues) => void; + onApply: (filters: ThreatActorFilterValues) => void; }; -function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) { - const [openFilter, setOpenFilter] = useState(null); - const [country, setCountry] = useState(""); - const [region, setRegion] = useState(""); - const [motivation, setMotivation] = useState(""); - const [status, setStatus] = useState(""); - const [dangerLevel, setDangerLevel] = useState(""); - const [sector, setSector] = useState(""); - const [sortBy, setSortBy] = useState("name-asc"); +function ThreatActorFilters({ + value, + onChange, + onApply, + filterOptions +}: ThreatActorFiltersProps) { + const [openFilter, setOpenFilter] = useState(null); - const toggleFilter = (filter: string) => { - setOpenFilter((previous) => - previous === filter ? null : filter - ); - }; + const {country,region, motivation, sector, status, dangerLevel, sortBy} = value; - const handleApplyFilters = () => { - setOpenFilter(null); + // Oppdaterer ett bestemt filter + const updateFilter = (key: keyof ThreatActorFilterValues, newValue: string) => {onChange({...value, [key]: newValue})}; - onApply({country,region,motivation,status,dangerLevel,sector,sortBy,}); -}; + const toggleFilter = (filter: string) => {setOpenFilter((previous) => previous === filter ? null : filter)}; + + const handleApplyFilters = () => { + setOpenFilter(null); + onApply(value); + }; + const handleResetFilters = () => { + setOpenFilter(null); + + const resetFilters = { ...DEFAULT_FILTERS }; + + onChange(resetFilters); + onApply(resetFilters); + }; + return (

Filters

@@ -71,7 +91,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={filterOptions?.countries ?? []} value={country} - onChange={setCountry} + onChange={(v) => updateFilter("country", v)} isOpen={openFilter === "Country"} onToggle={() => toggleFilter("Country")} /> @@ -82,7 +102,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={filterOptions?.regions ?? []} value={region} - onChange={setRegion} + onChange={(v) => updateFilter("region", v)} isOpen={openFilter === "Region"} onToggle={() => toggleFilter("Region")} /> @@ -93,7 +113,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={filterOptions?.motivations ?? []} value={motivation} - onChange={setMotivation} + onChange={(v) => updateFilter("motivation", v)} isOpen={openFilter === "Motivation"} onToggle={() => toggleFilter("Motivation")} /> @@ -104,7 +124,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) placeholder="All sectors" options={filterOptions?.sectors ?? []} value={sector} - onChange={setSector} + onChange={(v) => updateFilter("sector", v)} isOpen={openFilter === "Sector"} onToggle={() => toggleFilter("Sector")} /> @@ -115,7 +135,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={filterOptions?.statuses ?? []} value={status} - onChange={setStatus} + onChange={(v) => updateFilter("status", v)} isOpen={openFilter === "Status"} onToggle={() => toggleFilter("Status")} /> @@ -126,7 +146,7 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={filterOptions?.danger_levels ?? []} value={dangerLevel} - onChange={setDangerLevel} + onChange={(v) => updateFilter("dangerLevel", v)} isOpen={openFilter === "Danger Level"} onToggle={() => toggleFilter("Danger Level")} /> @@ -137,21 +157,31 @@ function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) icon={} options={sortOptions} value={sortBy} - onChange={setSortBy} + onChange={(v) => updateFilter("sortBy", v)} isOpen={openFilter === "Sort by"} onToggle={() => toggleFilter("Sort by")} showAllOption={false} /> -
- -
+
+ + + +
); diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css index 4c882f3..a11c218 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css @@ -66,7 +66,7 @@ } .filter-dropdown-text > span { - color: var(--text-muted, #94a9ab); + color: #dbe7e7; font-size: 12px; overflow: hidden; diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.css index 48ca3ed..466d447 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.css +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.css @@ -45,7 +45,9 @@ outline: none; background: transparent; - color: var(--text); + color: #f0f7f7; + font-size: 14px; + } .threat-actor-search button { diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.tsx b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.tsx index a9c1c1c..a9a05c5 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.tsx +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch.tsx @@ -1,21 +1,36 @@ import { Search } from "lucide-react"; import "./ThreatActorSearch.css"; +import {type SubmitEvent } from "react"; + +type ThreatActorSearchProps = { + value: string; + onChange: (value: string) => void; + onSearch: (searchWord: string) => void; +}; + +function ThreatActorSearch({ value, onChange, onSearch}: ThreatActorSearchProps) { + + const handleSearch = (event: SubmitEvent) => { + event.preventDefault(); + onSearch(value.trim()); + }; -function ThreatActorSearch() { return ( -
+
onChange(event.target.value)} /> - -
+ ); } diff --git a/frontend/src/graphql/queries/threatActorQueries.ts b/frontend/src/graphql/queries/threatActorQueries.ts index 769c730..a3a2dfc 100644 --- a/frontend/src/graphql/queries/threatActorQueries.ts +++ b/frontend/src/graphql/queries/threatActorQueries.ts @@ -2,11 +2,24 @@ import { gql, type TypedDocumentNode } from "@apollo/client"; import type { ThreatActorAssociatedVariables, ThreatActorDetailedData, ThreatActorDetailedVariables, ThreatActorFilterOptionsData, ThreatActorsGridData, ThreatActorsGridVariables, ThreatActorSoftwaresData, ThreatActorTechniquesData } from "../../types/ThreatActorTypes"; -//Query for ThreatActorGrid; viarbler {limit, offset} -//Bruker TypedDocumentNode for at VScode ikke klager. -export const GET_ALL_THREAT_ACTOR_GRID: TypedDocumentNode = gql` - query GetThreatActorsGrid($limit: Int!, $offset: Int!) { - allThreatActorsGrid(limit: $limit, offset: $offset) { +export const GET_ALL_THREAT_ACTOR_GRID: TypedDocumentNode< + ThreatActorsGridData, + ThreatActorsGridVariables +> = gql` + query GetThreatActorsGrid( + $limit: Int! + $offset: Int! + $search: String + $filters: ThreatActorFilterInput + $sortBy: String + ) { + allThreatActorsGrid( + limit: $limit + offset: $offset + search: $search + filters: $filters + sortBy: $sortBy + ) { totalCount items { name @@ -17,7 +30,8 @@ export const GET_ALL_THREAT_ACTOR_GRID: TypedDocumentNode = gql` query GetThreatActorByMitreId($mitreId: String!) { diff --git a/frontend/src/pages/ThreatActors/ThreatActors.tsx b/frontend/src/pages/ThreatActors/ThreatActors.tsx index 2c95a28..20de402 100644 --- a/frontend/src/pages/ThreatActors/ThreatActors.tsx +++ b/frontend/src/pages/ThreatActors/ThreatActors.tsx @@ -1,4 +1,4 @@ -import ThreatActorFilters from "../../components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter"; +import ThreatActorFilters, {DEFAULT_FILTERS, type ThreatActorFilterValues} from "../../components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter"; import ThreatActorGrid from "../../components/threatactor/ThreatActorMain/ThreatActorGrid/ThreatActorGrid"; import ThreatActorPageHeader from "../../components/threatactor/ThreatActorMain/ThreatActorPageHeader/ThreatActorPageHeader"; import ThreatActorSearch from "../../components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch"; @@ -6,16 +6,88 @@ import "./ThreatActors.css" import { useQuery } from "@apollo/client/react"; import { GET_ALL_THREAT_ACTOR_GRID, GET_THREAT_ACTOR_FILTER_OPTIONS } from "../../graphql/queries/threatActorQueries"; import { useState } from "react"; +import ThreatActorActiveFilters from "../../components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters"; + function ThreatActor(){ const pageSize = 9; const [page, setPage] = useState(1); + // Search + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + // Filters + const [draftFilters, setDraftFilters] = useState({ ...DEFAULT_FILTERS }); + const [activeFilters, setActiveFilters] = useState({ ...DEFAULT_FILTERS }); + + + /** + * Aktiverer valgte filtre. + */ + const handleApplyFilters = (filters: ThreatActorFilterValues) => { + setActiveFilters(filters); + setPage(1); + }; + + /** + * Aktiverer søkeordet. + */ + const handleSearch = (searchWord: string) => { + const normalizedSearch = searchWord.trim(); + + setSearchInput(normalizedSearch); + setSearch(normalizedSearch); + setPage(1); + }; + + /** + * Fjerner søkeordet fra både inputfeltet og GraphQL. + */ + const handleRemoveSearch = () => { + setSearchInput(""); + setSearch(""); + setPage(1); + }; + + /** + * Fjerner ett aktivt filter uten å påvirke de andre. + */ + const handleRemoveFilter = (key: keyof ThreatActorFilterValues) => { + setActiveFilters((previous) => ({ + ...previous, + [key]: DEFAULT_FILTERS[key], + })); + + setDraftFilters((previous) => ({ + ...previous, + [key]: DEFAULT_FILTERS[key], + })); + + setPage(1); + }; + + /** + * Nullstiller hele katalogtilstanden. + */ + const handleClearAll = () => { + setSearchInput(""); + setSearch(""); + + setDraftFilters({ ...DEFAULT_FILTERS }); + setActiveFilters({ ...DEFAULT_FILTERS }); + + setPage(1); + }; + + const { sortBy, ...filters } = activeFilters; const {data} = useQuery(GET_ALL_THREAT_ACTOR_GRID, { variables: { limit: pageSize, - offset: (page - 1) * pageSize + offset: (page - 1) * pageSize, + search, + filters, + sortBy, }, } ); @@ -31,11 +103,26 @@ function ThreatActor(){ return (
- + + console.log("Selected filters:", filters)} + onApply={handleApplyFilters} /> + +