@cryptotaxi247 / CoPilot / commits / d8bb5ed8

Virustotal ioc (#343)

* feat: Add VirusTotal threat intelligence endpoint and related functionality * feat: Implement VirusTotal threat intelligence endpoint and response models * feat: Update VirusTotal schema to allow optional fields for result and last_analysis_results * feat: Add verification check for VirusTotal connector before API invocation * chore: update dependencies in frontend * feat: add VirusTotalEnrichmentButton * refactor: improved some components * precommit fixes --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Nov 30, 2024 at 09:44 UTC d8bb5ed868e618cbc10f20fa7599729675717901
17 files changed +1303 -243
.vscode/settings.json
+3
@@ -12,6 +12,7 @@
12 "colord",
13 "commonmark",
14 "creationdate",
15 + "crowdsourced",
16 "Crowdstrike",
17 "darktrace",
18 "datejs",
@@ -32,6 +33,7 @@
33 "iconoir",
34 "Indicies",
35 "iocs",
36 + "jarm",
37 "lastupdate",
38 "linebreak",
39 "Logsource",
@@ -67,6 +69,7 @@
69 "virustotal",
70 "vuesjv",
71 "Wazuh",
72 + "whois",
73 "xaxis",
74 "zondicons"
75 ]
backend/app/threat_intel/routes/socfortress.py
+58
@@ -20,6 +20,9 @@ from app.threat_intel.schema.socfortress import SocfortressAiWazuhExclusionRuleR
20 from app.threat_intel.schema.socfortress import SocfortressProcessNameAnalysisRequest
21 from app.threat_intel.schema.socfortress import SocfortressProcessNameAnalysisResponse
22 from app.threat_intel.schema.socfortress import SocfortressThreatIntelRequest
23 +from app.threat_intel.schema.socfortress import VirusTotalThreatIntelRequest
24 +from app.threat_intel.schema.virustotal import VirusTotalRouteResponse
25 +from app.threat_intel.services.socfortress import invoke_virustotal_api
26 from app.threat_intel.services.socfortress import socfortress_ai_alert_lookup
27 from app.threat_intel.services.socfortress import socfortress_process_analysis_lookup
28 from app.threat_intel.services.socfortress import socfortress_threat_intel_lookup
@@ -96,6 +99,61 @@ async def threat_intel_socfortress(
99 return socfortress_lookup
100
101
102 +@threat_intel_socfortress_router.post(
103 + "/virustotal",
104 + response_model=VirusTotalRouteResponse,
105 + description="VirusTotal Enrichment Threat Intel",
106 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
107 +)
108 +async def threat_intel_virustotal(
109 + request: VirusTotalThreatIntelRequest,
110 + session: AsyncSession = Depends(get_db),
111 +):
112 + """
113 + Endpoint for VirusTotal Threat Intel.
114 +
115 + This endpoint allows authorized users with 'admin' or 'analyst' scope to perform VirusTotal threat intelligence lookup.
116 +
117 + Parameters:
118 + - request: VirusTotalThreatIntelRequest - The request payload containing the necessary information for the lookup.
119 + - session: AsyncSession (optional) - The database session to use for the lookup.
120 + - _key_exists: bool (optional) - A dependency to ensure the API key exists.
121 +
122 + Returns:
123 + - IoCResponse: The response model containing the results of the VirusTotal threat intelligence lookup.
124 + """
125 + logger.info("Running VirusTotal Threat Intel.")
126 +
127 + # Check if the connector is verified
128 + if not await get_connector_attribute(
129 + connector_name="VirusTotal",
130 + column_name="connector_verified",
131 + session=session,
132 + ):
133 + raise HTTPException(
134 + status_code=500,
135 + detail="VirusTotal connector is not verified.",
136 + )
137 +
138 + return VirusTotalRouteResponse(
139 + data=await invoke_virustotal_api(
140 + url=await get_connector_attribute(
141 + connector_name="VirusTotal",
142 + column_name="connector_url",
143 + session=session,
144 + ),
145 + api_key=await get_connector_attribute(
146 + connector_name="VirusTotal",
147 + column_name="connector_api_key",
148 + session=session,
149 + ),
150 + request=request,
151 + ),
152 + success=True,
153 + message="VirusTotal threat intelligence lookup was successful.",
154 + )
155 +
156 +
157 @threat_intel_socfortress_router.post(
158 "/process_name",
159 response_model=SocfortressProcessNameAnalysisResponse,
backend/app/threat_intel/schema/socfortress.py
+4
@@ -17,6 +17,10 @@ class SocfortressThreatIntelRequest(BaseModel):
17 )
18
19
20 +class VirusTotalThreatIntelRequest(BaseModel):
21 + ioc_value: str
22 +
23 +
24 class IoCMapping(BaseModel):
25 comment: Optional[str] = Field(None, description="Comment about the IOCs")
26 ioc_source: str = Field(
backend/app/threat_intel/schema/virustotal.py new
+79
@@ -0,0 +1,79 @@
1 +from typing import Dict
2 +from typing import List
3 +from typing import Optional
4 +
5 +from pydantic import BaseModel
6 +from pydantic import Extra
7 +from pydantic import Field
8 +
9 +
10 +class AnalysisResult(BaseModel):
11 + method: str
12 + engine_name: str
13 + category: str
14 + result: Optional[str] = Field(default=None)
15 +
16 + class Config:
17 + extra = Extra.allow
18 +
19 +
20 +class TotalVotes(BaseModel):
21 + harmless: int
22 + malicious: int
23 +
24 + class Config:
25 + extra = Extra.allow
26 +
27 +
28 +class Attributes(BaseModel):
29 + total_votes: TotalVotes
30 + last_analysis_results: Optional[Dict[str, AnalysisResult]] = Field(default=None)
31 + regional_internet_registry: Optional[str] = Field(default=None)
32 + continent: Optional[str] = Field(default=None)
33 + last_modification_date: Optional[int] = Field(default=None)
34 + crowdsourced_context: Optional[List[Dict[str, str]]] = Field(default=None)
35 + tags: Optional[List[str]] = Field(default=None)
36 + asn: Optional[int] = Field(default=None)
37 + whois: Optional[str] = Field(default=None)
38 + whois_date: Optional[int] = Field(default=None)
39 + reputation: Optional[int] = Field(default=None)
40 + last_analysis_date: Optional[int] = Field(default=None)
41 + jarm: Optional[str] = Field(default=None)
42 + country: Optional[str] = Field(default=None)
43 + as_owner: Optional[str] = Field(default=None)
44 + last_analysis_stats: Optional[Dict[str, int]] = Field(default=None)
45 + last_https_certificate_date: Optional[int] = Field(default=None)
46 + network: Optional[str] = Field(default=None)
47 +
48 + class Config:
49 + extra = Extra.allow
50 +
51 +
52 +class Links(BaseModel):
53 + self: str
54 +
55 + class Config:
56 + extra = Extra.allow
57 +
58 +
59 +class Data(BaseModel):
60 + id: str
61 + type: str
62 + links: Links
63 + attributes: Attributes
64 +
65 + class Config:
66 + extra = Extra.allow
67 +
68 +
69 +class VirusTotalResponse(BaseModel):
70 + data: Data
71 +
72 + class Config:
73 + extra = Extra.allow
74 +
75 +
76 +class VirusTotalRouteResponse(BaseModel):
77 + data: VirusTotalResponse
78 + success: bool
79 + message: str
backend/app/threat_intel/services/socfortress.py
+78
@@ -1,3 +1,4 @@
1 +import re
2 from typing import Any
3 from typing import Dict
4
@@ -19,6 +20,7 @@ from app.threat_intel.schema.socfortress import (
20 from app.threat_intel.schema.socfortress import SocfortressProcessNameAnalysisRequest
21 from app.threat_intel.schema.socfortress import SocfortressProcessNameAnalysisResponse
22 from app.threat_intel.schema.socfortress import SocfortressThreatIntelRequest
23 +from app.threat_intel.schema.virustotal import VirusTotalResponse
24 from app.utils import get_connector_attribute
25
26
@@ -150,6 +152,82 @@ async def invoke_socfortress_threat_intel_api(
152 return response.json()
153
154
155 +def determine_ioc_type(ioc_value: str) -> str:
156 + """
157 + Determine the type of the IOC value and return the appropriate endpoint.
158 +
159 + Args:
160 + ioc_value (str): The IOC value.
161 +
162 + Returns:
163 + str: The endpoint for the IOC value.
164 +
165 + Raises:
166 + ValueError: If the IOC value is invalid.
167 + """
168 + ip_pattern = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$")
169 + domain_pattern = re.compile(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$")
170 + hash_pattern = re.compile(r"^[a-fA-F0-9]{32}$|^[a-fA-F0-9]{40}$|^[a-fA-F0-9]{64}$")
171 +
172 + if ip_pattern.match(ioc_value):
173 + return f"/ip_addresses/{ioc_value}"
174 + elif domain_pattern.match(ioc_value):
175 + return f"/domains/{ioc_value}"
176 + elif hash_pattern.match(ioc_value):
177 + return f"/files/{ioc_value}"
178 + else:
179 + raise HTTPException(
180 + status_code=400,
181 + detail="Invalid IOC value provided. Only IP addresses, domains, and hashes are supported.",
182 + )
183 +
184 +
185 +async def fetch_virustotal_data(api_key: str, full_url: str) -> dict:
186 + """
187 + Fetch data from the VirusTotal API.
188 +
189 + Args:
190 + api_key (str): The API key for authentication.
191 + full_url (str): The full URL of the VirusTotal API endpoint.
192 +
193 + Returns:
194 + dict: The JSON response from the VirusTotal API.
195 +
196 + Raises:
197 + httpx.HTTPStatusError: If the API request fails with a non-successful status code.
198 + """
199 + headers = {"x-apikey": api_key}
200 + async with httpx.AsyncClient() as client:
201 + response = await client.get(full_url, headers=headers)
202 + response.raise_for_status()
203 + return VirusTotalResponse.parse_obj(response.json())
204 +
205 +
206 +async def invoke_virustotal_api(
207 + api_key: str,
208 + url: str,
209 + request: SocfortressThreatIntelRequest,
210 +) -> dict:
211 + """
212 + Invokes the VirusTotal API with the provided API key, URL, and request parameters.
213 +
214 + Args:
215 + api_key (str): The API key for authentication.
216 + url (str): The base URL of the VirusTotal API.
217 + request (SocfortressThreatIntelRequest): The request object containing the IOC value and customer code.
218 +
219 + Returns:
220 + dict: The JSON response from the VirusTotal API.
221 +
222 + Raises:
223 + httpx.HTTPStatusError: If the API request fails with a non-successful status code.
224 + """
225 + ioc_value = request.ioc_value
226 + endpoint = determine_ioc_type(ioc_value)
227 + full_url = f"{url}{endpoint}"
228 + return await fetch_virustotal_data(api_key, full_url)
229 +
230 +
231 async def invoke_socfortress_process_name_api(
232 api_key: str,
233 url: str,
frontend/figma-tokens.json
+4 -4
@@ -1,11 +1,11 @@
1 {
2 "global": {
3 "border-radius-base": {
4 - "value": "6px",
4 + "value": "8px",
5 "type": "borderRadius"
6 },
7 "border-radius-small": {
8 - "value": "3px",
8 + "value": "4px",
9 "type": "borderRadius"
10 },
11 "line-heights-base": {
@@ -13,7 +13,7 @@
13 "type": "lineHeights"
14 },
15 "font-sizes-base": {
16 - "value": "15px",
16 + "value": "16px",
17 "type": "fontSizes"
18 },
19 "font-sizes-card-title": {
@@ -802,7 +802,7 @@
802 "value": {
803 "fontFamily": "{font-families-base}",
804 "fontSize": "{font-sizes-base}",
805 - "lineHeight": "20"
805 + "lineHeight": "22"
806 },
807 "type": "typography"
808 }
frontend/package-lock.json
+259 -182
@@ -13,7 +13,7 @@
13 "@fontsource/jetbrains-mono": "^5.1.1",
14 "@fontsource/lexend": "^5.1.1",
15 "@fontsource/public-sans": "^5.1.1",
16 - "@shikijs/markdown-it": "^1.23.1",
16 + "@shikijs/markdown-it": "^1.24.0",
17 "@tailwindcss/container-queries": "^0.1.1",
18 "@vueuse/core": "^12.0.0",
19 "axios": "^1.7.8",
@@ -31,15 +31,15 @@
31 "naive-ui": "^2.40.2",
32 "nanoid": "^5.0.9",
33 "password-validator": "^5.3.0",
34 - "pinia": "^2.2.7",
34 + "pinia": "^2.2.8",
35 "pinia-plugin-persistedstate": "^4.1.3",
36 "secure-ls": "^2.0.0",
37 - "shiki": "^1.23.1",
37 + "shiki": "^1.24.0",
38 "validator": "^13.12.0",
39 "vue": "^3.5.13",
40 "vue-advanced-cropper": "^2.8.9",
41 "vue-highlight-words": "^3.0.1",
42 - "vue-i18n": "^10.0.4",
42 + "vue-i18n": "^10.0.5",
43 "vue-router": "^4.5.0",
44 "vue-sjv": "^0.0.6",
45 "vue3-apexcharts": "^1.8.0",
@@ -47,7 +47,7 @@
47 "vuedraggable": "^4.1.0"
48 },
49 "devDependencies": {
50 - "@antfu/eslint-config": "^3.11.0",
50 + "@antfu/eslint-config": "^3.11.2",
51 "@clack/prompts": "^0.8.2",
52 "@iconify/vue": "^4.1.2",
53 "@tsconfig/node20": "^20.1.4",
@@ -56,16 +56,16 @@
56 "@types/fs-extra": "^11.0.4",
57 "@types/jsdom": "^21.1.7",
58 "@types/lodash": "^4.17.13",
59 - "@types/node": "^22.10.0",
59 + "@types/node": "^22.10.1",
60 "@types/validator": "^13.12.2",
61 "@vitejs/plugin-vue": "^5.2.1",
62 "@vitejs/plugin-vue-jsx": "^4.1.1",
63 "@vue/test-utils": "^2.4.6",
64 - "@vue/tsconfig": "^0.6.0",
64 + "@vue/tsconfig": "^0.7.0",
65 "autoprefixer": "^10.4.20",
66 "cypress": "^13.16.0",
67 "depcheck": "^1.4.7",
68 - "eslint": "^9.15.0",
68 + "eslint": "^9.16.0",
69 "flourite": "^1.3.0",
70 "fs-extra": "^11.2.0",
71 "jsdom": "^25.0.1",
@@ -83,7 +83,7 @@
83 "unplugin-vue-components": "^0.27.5",
84 "vite": "^5.4.11",
85 "vite-bundle-visualizer": "^1.2.1",
86 - "vite-plugin-vue-devtools": "^7.6.5",
86 + "vite-plugin-vue-devtools": "^7.6.7",
87 "vite-svg-loader": "^5.1.0",
88 "vitest": "^2.1.6",
89 "vue-tsc": "^2.1.10"
@@ -130,9 +130,9 @@
130 }
131 },
132 "node_modules/@antfu/eslint-config": {
133 - "version": "3.11.0",
134 - "resolved": "https://registry.npmjs.org/@antfu/eslint-config/-/eslint-config-3.11.0.tgz",
135 - "integrity": "sha512-yJ8xkY7qtSoZpTOEOo3gMRsiYYvhNXHaWWh1APN0brDZsmRSjSPJBxMgq+JG+6hjeToJh9koZQxzDiwCbmirEg==",
133 + "version": "3.11.2",
134 + "resolved": "https://registry.npmjs.org/@antfu/eslint-config/-/eslint-config-3.11.2.tgz",
135 + "integrity": "sha512-hoi2MnOdiKL8mIhpMtinwMrqVPq6QVbHPA+BuQD4pqE6yVLyYvjdLFiKApMsezAM+YofCsbhak2oY+JCiIyeNA==",
136 "dev": true,
137 "license": "MIT",
138 "dependencies": {
@@ -143,7 +143,7 @@
143 "@stylistic/eslint-plugin": "^2.11.0",
144 "@typescript-eslint/eslint-plugin": "^8.16.0",
145 "@typescript-eslint/parser": "^8.16.0",
146 - "@vitest/eslint-plugin": "^1.1.11",
146 + "@vitest/eslint-plugin": "^1.1.12",
147 "eslint-config-flat-gitignore": "^0.3.0",
148 "eslint-flat-config-utils": "^0.4.0",
149 "eslint-merge-processors": "^0.1.0",
@@ -1499,9 +1499,9 @@
1499 }
1500 },
1501 "node_modules/@eslint/js": {
1502 - "version": "9.15.0",
1503 - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.15.0.tgz",
1504 - "integrity": "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg==",
1502 + "version": "9.16.0",
1503 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.16.0.tgz",
1504 + "integrity": "sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==",
1505 "dev": true,
1506 "license": "MIT",
1507 "engines": {
@@ -1684,13 +1684,13 @@
1684 }
1685 },
1686 "node_modules/@intlify/core-base": {
1687 - "version": "10.0.4",
1688 - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.4.tgz",
1689 - "integrity": "sha512-GG428DkrrWCMhxRMRQZjuS7zmSUzarYcaHJqG9VB8dXAxw4iQDoKVQ7ChJRB6ZtsCsX3Jse1PEUlHrJiyQrOTg==",
1687 + "version": "10.0.5",
1688 + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.5.tgz",
1689 + "integrity": "sha512-F3snDTQs0MdvnnyzTDTVkOYVAZOE/MHwRvF7mn7Jw1yuih4NrFYLNYIymGlLmq4HU2iIdzYsZ7f47bOcwY73XQ==",
1690 "license": "MIT",
1691 "dependencies": {
1692 - "@intlify/message-compiler": "10.0.4",
1693 - "@intlify/shared": "10.0.4"
1692 + "@intlify/message-compiler": "10.0.5",
1693 + "@intlify/shared": "10.0.5"
1694 },
1695 "engines": {
1696 "node": ">= 16"
@@ -1700,12 +1700,12 @@
1700 }
1701 },
1702 "node_modules/@intlify/message-compiler": {
1703 - "version": "10.0.4",
1704 - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.4.tgz",
1705 - "integrity": "sha512-AFbhEo10DP095/45EauinQJ5hJ3rJUmuuqltGguvc3WsvezZN+g8qNHLGWKu60FHQVizMrQY7VJ+zVlBXlQQkQ==",
1703 + "version": "10.0.5",
1704 + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.5.tgz",
1705 + "integrity": "sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==",
1706 "license": "MIT",
1707 "dependencies": {
1708 - "@intlify/shared": "10.0.4",
1708 + "@intlify/shared": "10.0.5",
1709 "source-map-js": "^1.0.2"
1710 },
1711 "engines": {
@@ -1716,9 +1716,9 @@
1716 }
1717 },
1718 "node_modules/@intlify/shared": {
1719 - "version": "10.0.4",
1720 - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.4.tgz",
1721 - "integrity": "sha512-ukFn0I01HsSgr3VYhYcvkTCLS7rGa0gw4A4AMpcy/A9xx/zRJy7PS2BElMXLwUazVFMAr5zuiTk3MQeoeGXaJg==",
1719 + "version": "10.0.5",
1720 + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.5.tgz",
1721 + "integrity": "sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==",
1722 "license": "MIT",
1723 "engines": {
1724 "node": ">= 16"
@@ -2584,55 +2584,62 @@
2584 "win32"
2585 ]
2586 },
2587 + "node_modules/@sec-ant/readable-stream": {
2588 + "version": "0.4.1",
2589 + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
2590 + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
2591 + "dev": true,
2592 + "license": "MIT"
2593 + },
2594 "node_modules/@shikijs/core": {
2588 - "version": "1.23.1",
2589 - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.23.1.tgz",
2590 - "integrity": "sha512-NuOVgwcHgVC6jBVH5V7iblziw6iQbWWHrj5IlZI3Fqu2yx9awH7OIQkXIcsHsUmY19ckwSgUMgrqExEyP5A0TA==",
2595 + "version": "1.24.0",
2596 + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.24.0.tgz",
2597 + "integrity": "sha512-6pvdH0KoahMzr6689yh0QJ3rCgF4j1XsXRHNEeEN6M4xJTfQ6QPWrmHzIddotg+xPJUPEPzYzYCKzpYyhTI6Gw==",
2598 "license": "MIT",
2599 "dependencies": {
2593 - "@shikijs/engine-javascript": "1.23.1",
2594 - "@shikijs/engine-oniguruma": "1.23.1",
2595 - "@shikijs/types": "1.23.1",
2600 + "@shikijs/engine-javascript": "1.24.0",
2601 + "@shikijs/engine-oniguruma": "1.24.0",
2602 + "@shikijs/types": "1.24.0",
2603 "@shikijs/vscode-textmate": "^9.3.0",
2604 "@types/hast": "^3.0.4",
2605 "hast-util-to-html": "^9.0.3"
2606 }
2607 },
2608 "node_modules/@shikijs/engine-javascript": {
2602 - "version": "1.23.1",
2603 - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.23.1.tgz",
2604 - "integrity": "sha512-i/LdEwT5k3FVu07SiApRFwRcSJs5QM9+tod5vYCPig1Ywi8GR30zcujbxGQFJHwYD7A5BUqagi8o5KS+LEVgBg==",
2609 + "version": "1.24.0",
2610 + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.24.0.tgz",
2611 + "integrity": "sha512-ZA6sCeSsF3Mnlxxr+4wGEJ9Tto4RHmfIS7ox8KIAbH0MTVUkw3roHPHZN+LlJMOHJJOVupe6tvuAzRpN8qK1vA==",
2612 "license": "MIT",
2613 "dependencies": {
2607 - "@shikijs/types": "1.23.1",
2614 + "@shikijs/types": "1.24.0",
2615 "@shikijs/vscode-textmate": "^9.3.0",
2609 - "oniguruma-to-es": "0.4.1"
2616 + "oniguruma-to-es": "0.7.0"
2617 }
2618 },
2619 "node_modules/@shikijs/engine-oniguruma": {
2613 - "version": "1.23.1",
2614 - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.23.1.tgz",
2615 - "integrity": "sha512-KQ+lgeJJ5m2ISbUZudLR1qHeH3MnSs2mjFg7bnencgs5jDVPeJ2NVDJ3N5ZHbcTsOIh0qIueyAJnwg7lg7kwXQ==",
2620 + "version": "1.24.0",
2621 + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.0.tgz",
2622 + "integrity": "sha512-Eua0qNOL73Y82lGA4GF5P+G2+VXX9XnuUxkiUuwcxQPH4wom+tE39kZpBFXfUuwNYxHSkrSxpB1p4kyRW0moSg==",
2623 "license": "MIT",
2624 "dependencies": {
2618 - "@shikijs/types": "1.23.1",
2625 + "@shikijs/types": "1.24.0",
2626 "@shikijs/vscode-textmate": "^9.3.0"
2627 }
2628 },
2629 "node_modules/@shikijs/markdown-it": {
2623 - "version": "1.23.1",
2624 - "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-1.23.1.tgz",
2625 - "integrity": "sha512-Odpj0AiQBe4v6D+XwAQkdErxncVnaBt+nZTc2JDrwWrOjvkM5JfRG55n9idTqGZfO0EMAZrhP7fmstNJ0yKmlg==",
2630 + "version": "1.24.0",
2631 + "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-1.24.0.tgz",
2632 + "integrity": "sha512-YjYg8jJoTO0cUXUNlFHTZWWFt4wSDOcRd2nM2aB1rnX5RqRlcqwfS2x1vQjlPqmUisv+/GHClvz7uKHeK7ZDBw==",
2633 "license": "MIT",
2634 "dependencies": {
2635 "markdown-it": "^14.1.0",
2629 - "shiki": "1.23.1"
2636 + "shiki": "1.24.0"
2637 }
2638 },
2639 "node_modules/@shikijs/types": {
2633 - "version": "1.23.1",
2634 - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.23.1.tgz",
2635 - "integrity": "sha512-98A5hGyEhzzAgQh2dAeHKrWW4HfCMeoFER2z16p5eJ+vmPeF6lZ/elEne6/UCU551F/WqkopqRsr1l2Yu6+A0g==",
2640 + "version": "1.24.0",
2641 + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.24.0.tgz",
2642 + "integrity": "sha512-aptbEuq1Pk88DMlCe+FzXNnBZ17LCiLIGWAeCWhoFDzia5Q5Krx3DgnULLiouSdd6+LUM39XwXGppqYE0Ghtug==",
2643 "license": "MIT",
2644 "dependencies": {
2645 "@shikijs/vscode-textmate": "^9.3.0",
@@ -2912,9 +2919,9 @@
2919 "license": "MIT"
2920 },
2921 "node_modules/@types/node": {
2915 - "version": "22.10.0",
2916 - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz",
2917 - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==",
2922 + "version": "22.10.1",
2923 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz",
2924 + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==",
2925 "dev": true,
2926 "license": "MIT",
2927 "dependencies": {
@@ -3515,50 +3522,31 @@
3522 "license": "MIT"
3523 },
3524 "node_modules/@vue/devtools-core": {
3518 - "version": "7.6.5",
3519 - "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.6.5.tgz",
3520 - "integrity": "sha512-PKTEZVzY4Ef6G8LnbACKkPDOcdr2snFn3Xk8YqyFgugmogDrA3cyYVQ58CS0XTO9AYUXU9E5FFt5JJf22kXF2w==",
3525 + "version": "7.6.7",
3526 + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.6.7.tgz",
3527 + "integrity": "sha512-6fW8Q0H1NHDXdEcuV6dylT5U2Yxg3SdMnVCey99Y6S4R2PNgFL2vC+VU9U9rHIiaoEUkeza42S7FfHxV4VI3Jg==",
3528 "dev": true,
3529 "license": "MIT",
3530 "dependencies": {
3524 - "@vue/devtools-kit": "^7.6.5",
3525 - "@vue/devtools-shared": "^7.6.5",
3531 + "@vue/devtools-kit": "^7.6.7",
3532 + "@vue/devtools-shared": "^7.6.7",
3533 "mitt": "^3.0.1",
3527 - "nanoid": "^3.3.4",
3534 + "nanoid": "^5.0.9",
3535 "pathe": "^1.1.2",
3529 - "vite-hot-client": "^0.2.3"
3536 + "vite-hot-client": "^0.2.4"
3537 },
3538 "peerDependencies": {
3539 "vue": "^3.0.0"
3540 }
3541 },
3535 - "node_modules/@vue/devtools-core/node_modules/nanoid": {
3536 - "version": "3.3.8",
3537 - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
3538 - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
3539 - "dev": true,
3540 - "funding": [
3541 - {
3542 - "type": "github",
3543 - "url": "https://github.com/sponsors/ai"
3544 - }
3545 - ],
3546 - "license": "MIT",
3547 - "bin": {
3548 - "nanoid": "bin/nanoid.cjs"
3549 - },
3550 - "engines": {
3551 - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
3552 - }
3553 - },
3542 "node_modules/@vue/devtools-kit": {
3555 - "version": "7.6.5",
3556 - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.6.5.tgz",
3557 - "integrity": "sha512-fLQhUwmUbtEDHW1SEiHUF5k2Ptw816As5ZUVb/SzrqkrJzXI8xjEIo8suNBe/N+ewdz/9m5ayeFH8fmcVIbr4Q==",
3543 + "version": "7.6.7",
3544 + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.6.7.tgz",
3545 + "integrity": "sha512-V8/jrXY/swHgnblABG9U4QCbE60c6RuPasmv2d9FvVqc5d94t1vDiESuvRmdNJBdWz4/D3q6ffgyAfRVjwHYEw==",
3546 "dev": true,
3547 "license": "MIT",
3548 "dependencies": {
3561 - "@vue/devtools-shared": "^7.6.5",
3549 + "@vue/devtools-shared": "^7.6.7",
3550 "birpc": "^0.2.19",
3551 "hookable": "^5.5.3",
3552 "mitt": "^3.0.1",
@@ -3568,9 +3556,9 @@
3556 }
3557 },
3558 "node_modules/@vue/devtools-shared": {
3571 - "version": "7.6.5",
3572 - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.6.5.tgz",
3573 - "integrity": "sha512-szsXQ0jlpjuFfmxb6F40qkSF4gtLC1W+dKRh/UiTulC+RekZsjqcN/qnVFkzqOO1YnzzShinZwfmv+MbfPJnpw==",
3559 + "version": "7.6.7",
3560 + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.6.7.tgz",
3561 + "integrity": "sha512-QggO6SviAsolrePAXZ/sA1dSicSPt4TueZibCvydfhNDieL1lAuyMTgQDGst7TEvMGb4vgYv2I+1sDkO4jWNnw==",
3562 "dev": true,
3563 "license": "MIT",
3564 "dependencies": {
@@ -3664,14 +3652,14 @@
3652 }
3653 },
3654 "node_modules/@vue/tsconfig": {
3667 - "version": "0.6.0",
3668 - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.6.0.tgz",
3669 - "integrity": "sha512-MHXNd6lzugsEHvuA6l1GqrF5jROqUon8sP/HInLPnthJiYvB0VvpHMywg7em1dBZfFZNBSkR68qH37zOdRHmCw==",
3655 + "version": "0.7.0",
3656 + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz",
3657 + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==",
3658 "dev": true,
3659 "license": "MIT",
3660 "peerDependencies": {
3661 "typescript": "5.x",
3674 - "vue": "^3.3.0"
3662 + "vue": "^3.4.0"
3663 },
3664 "peerDependenciesMeta": {
3665 "typescript": {
@@ -6067,9 +6055,9 @@
6055 }
6056 },
6057 "node_modules/eslint": {
6070 - "version": "9.15.0",
6071 - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.15.0.tgz",
6072 - "integrity": "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw==",
6058 + "version": "9.16.0",
6059 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.16.0.tgz",
6060 + "integrity": "sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==",
6061 "dev": true,
6062 "license": "MIT",
6063 "dependencies": {
@@ -6078,7 +6066,7 @@
6066 "@eslint/config-array": "^0.19.0",
6067 "@eslint/core": "^0.9.0",
6068 "@eslint/eslintrc": "^3.2.0",
6081 - "@eslint/js": "9.15.0",
6069 + "@eslint/js": "9.16.0",
6070 "@eslint/plugin-kit": "^0.2.3",
6071 "@humanfs/node": "^0.16.6",
6072 "@humanwhocodes/module-importer": "^1.0.1",
@@ -8347,6 +8335,19 @@
8335 "node": ">=8"
8336 }
8337 },
8338 + "node_modules/is-plain-obj": {
8339 + "version": "4.1.0",
8340 + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
8341 + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
8342 + "dev": true,
8343 + "license": "MIT",
8344 + "engines": {
8345 + "node": ">=12"
8346 + },
8347 + "funding": {
8348 + "url": "https://github.com/sponsors/sindresorhus"
8349 + }
8350 + },
8351 "node_modules/is-potential-custom-element-name": {
8352 "version": "1.0.1",
8353 "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -10915,14 +10916,14 @@
10916 }
10917 },
10918 "node_modules/oniguruma-to-es": {
10918 - "version": "0.4.1",
10919 - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-0.4.1.tgz",
10920 - "integrity": "sha512-rNcEohFz095QKGRovP/yqPIKc+nP+Sjs4YTHMv33nMePGKrq/r2eu9Yh4646M5XluGJsUnmwoXuiXE69KDs+fQ==",
10919 + "version": "0.7.0",
10920 + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-0.7.0.tgz",
10921 + "integrity": "sha512-HRaRh09cE0gRS3+wi2zxekB+I5L8C/gN60S+vb11eADHUaB/q4u8wGGOX3GvwvitG8ixaeycZfeoyruKQzUgNg==",
10922 "license": "MIT",
10923 "dependencies": {
10924 "emoji-regex-xs": "^1.0.0",
10924 - "regex": "^5.0.0",
10925 - "regex-recursion": "^4.2.1"
10925 + "regex": "^5.0.2",
10926 + "regex-recursion": "^4.3.0"
10927 }
10928 },
10929 "node_modules/only": {
@@ -11100,6 +11101,19 @@
11101 "url": "https://github.com/sponsors/sindresorhus"
11102 }
11103 },
11104 + "node_modules/parse-ms": {
11105 + "version": "4.0.0",
11106 + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
11107 + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
11108 + "dev": true,
11109 + "license": "MIT",
11110 + "engines": {
11111 + "node": ">=18"
11112 + },
11113 + "funding": {
11114 + "url": "https://github.com/sponsors/sindresorhus"
11115 + }
11116 + },
11117 "node_modules/parse-passwd": {
11118 "version": "1.0.0",
11119 "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
@@ -11313,9 +11327,9 @@
11327 }
11328 },
11329 "node_modules/pinia": {
11316 - "version": "2.2.7",
11317 - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.7.tgz",
11318 - "integrity": "sha512-M+X9Eh9V5De+8wyj0rD1cgB0zy1mPN/aBEpCI9y+DgVmzXV2dIwjYBluJ5cMQd/jAoHs0VW+EyUSHMZv/Wtcnw==",
11330 + "version": "2.2.8",
11331 + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.8.tgz",
11332 + "integrity": "sha512-NRTYy2g+kju5tBRe0oNlriZIbMNvma8ZJrpHsp3qudyiMEA8jMmPPKQ2QMHg0Oc4BkUyQYWagACabrwriCK9HQ==",
11333 "license": "MIT",
11334 "dependencies": {
11335 "@vue/devtools-api": "^6.6.3",
@@ -11766,6 +11780,22 @@
11780 "url": "https://github.com/sponsors/sindresorhus"
11781 }
11782 },
11783 + "node_modules/pretty-ms": {
11784 + "version": "9.2.0",
11785 + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz",
11786 + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==",
11787 + "dev": true,
11788 + "license": "MIT",
11789 + "dependencies": {
11790 + "parse-ms": "^4.0.0"
11791 + },
11792 + "engines": {
11793 + "node": ">=18"
11794 + },
11795 + "funding": {
11796 + "url": "https://github.com/sponsors/sindresorhus"
11797 + }
11798 + },
11799 "node_modules/process": {
11800 "version": "0.11.10",
11801 "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -12081,9 +12111,9 @@
12111 }
12112 },
12113 "node_modules/regex-recursion": {
12084 - "version": "4.2.1",
12085 - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-4.2.1.tgz",
12086 - "integrity": "sha512-QHNZyZAeKdndD1G3bKAbBEKOSSK4KOHQrAJ01N1LJeb0SoH4DJIeFhp0uUpETgONifS4+P3sOgoA1dhzgrQvhA==",
12114 + "version": "4.3.0",
12115 + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-4.3.0.tgz",
12116 + "integrity": "sha512-5LcLnizwjcQ2ALfOj95MjcatxyqF5RPySx9yT+PaXu3Gox2vyAtLDjHB8NTJLtMGkvyau6nI3CfpwFCjPUIs/A==",
12117 "license": "MIT",
12118 "dependencies": {
12119 "regex-utilities": "^2.3.0"
@@ -12705,15 +12735,15 @@
12735 }
12736 },
12737 "node_modules/shiki": {
12708 - "version": "1.23.1",
12709 - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.23.1.tgz",
12710 - "integrity": "sha512-8kxV9TH4pXgdKGxNOkrSMydn1Xf6It8lsle0fiqxf7a1149K1WGtdOu3Zb91T5r1JpvRPxqxU3C2XdZZXQnrig==",
12738 + "version": "1.24.0",
12739 + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.24.0.tgz",
12740 + "integrity": "sha512-qIneep7QRwxRd5oiHb8jaRzH15V/S8F3saCXOdjwRLgozZJr5x2yeBhQtqkO3FSzQDwYEFAYuifg4oHjpDghrg==",
12741 "license": "MIT",
12742 "dependencies": {
12713 - "@shikijs/core": "1.23.1",
12714 - "@shikijs/engine-javascript": "1.23.1",
12715 - "@shikijs/engine-oniguruma": "1.23.1",
12716 - "@shikijs/types": "1.23.1",
12743 + "@shikijs/core": "1.24.0",
12744 + "@shikijs/engine-javascript": "1.24.0",
12745 + "@shikijs/engine-oniguruma": "1.24.0",
12746 + "@shikijs/types": "1.24.0",
12747 "@shikijs/vscode-textmate": "^9.3.0",
12748 "@types/hast": "^3.0.4"
12749 }
@@ -14557,19 +14587,19 @@
14587 }
14588 },
14589 "node_modules/vite-plugin-vue-devtools": {
14560 - "version": "7.6.5",
14561 - "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.6.5.tgz",
14562 - "integrity": "sha512-5ISMSoLMrOl/77suAC3DigbuI4oSsWW7fgwdAoKbKvtY6+L3Jv51mjCnirzRog2uP0K59iIXwHHtORUg1aBQ2A==",
14590 + "version": "7.6.7",
14591 + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.6.7.tgz",
14592 + "integrity": "sha512-H1ZyjtpWjP5mHA5R15sQeYgAARuh2Myg3TDFXWZK6QOQRy8s3XjTIt319DogVjU/x3rC3L/jJQjIasRU04mWXA==",
14593 "dev": true,
14594 "license": "MIT",
14595 "dependencies": {
14566 - "@vue/devtools-core": "^7.6.5",
14567 - "@vue/devtools-kit": "^7.6.5",
14568 - "@vue/devtools-shared": "^7.6.5",
14569 - "execa": "^8.0.1",
14596 + "@vue/devtools-core": "^7.6.7",
14597 + "@vue/devtools-kit": "^7.6.7",
14598 + "@vue/devtools-shared": "^7.6.7",
14599 + "execa": "^9.5.1",
14600 "sirv": "^3.0.0",
14571 - "vite-plugin-inspect": "^0.8.8",
14572 - "vite-plugin-vue-inspector": "^5.3.0"
14601 + "vite-plugin-inspect": "0.8.8",
14602 + "vite-plugin-vue-inspector": "^5.3.1"
14603 },
14604 "engines": {
14605 "node": ">=v14.21.3"
@@ -14578,106 +14608,127 @@
14608 "vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
14609 }
14610 },
14611 + "node_modules/vite-plugin-vue-devtools/node_modules/@sindresorhus/merge-streams": {
14612 + "version": "4.0.0",
14613 + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
14614 + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
14615 + "dev": true,
14616 + "license": "MIT",
14617 + "engines": {
14618 + "node": ">=18"
14619 + },
14620 + "funding": {
14621 + "url": "https://github.com/sponsors/sindresorhus"
14622 + }
14623 + },
14624 "node_modules/vite-plugin-vue-devtools/node_modules/execa": {
14582 - "version": "8.0.1",
14583 - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
14584 - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
14625 + "version": "9.5.1",
14626 + "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.1.tgz",
14627 + "integrity": "sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==",
14628 "dev": true,
14629 "license": "MIT",
14630 "dependencies": {
14631 + "@sindresorhus/merge-streams": "^4.0.0",
14632 "cross-spawn": "^7.0.3",
14589 - "get-stream": "^8.0.1",
14590 - "human-signals": "^5.0.0",
14591 - "is-stream": "^3.0.0",
14592 - "merge-stream": "^2.0.0",
14593 - "npm-run-path": "^5.1.0",
14594 - "onetime": "^6.0.0",
14633 + "figures": "^6.1.0",
14634 + "get-stream": "^9.0.0",
14635 + "human-signals": "^8.0.0",
14636 + "is-plain-obj": "^4.1.0",
14637 + "is-stream": "^4.0.1",
14638 + "npm-run-path": "^6.0.0",
14639 + "pretty-ms": "^9.0.0",
14640 "signal-exit": "^4.1.0",
14596 - "strip-final-newline": "^3.0.0"
14641 + "strip-final-newline": "^4.0.0",
14642 + "yoctocolors": "^2.0.0"
14643 },
14644 "engines": {
14599 - "node": ">=16.17"
14645 + "node": "^18.19.0 || >=20.5.0"
14646 },
14647 "funding": {
14648 "url": "https://github.com/sindresorhus/execa?sponsor=1"
14649 }
14650 },
14651 + "node_modules/vite-plugin-vue-devtools/node_modules/figures": {
14652 + "version": "6.1.0",
14653 + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
14654 + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
14655 + "dev": true,
14656 + "license": "MIT",
14657 + "dependencies": {
14658 + "is-unicode-supported": "^2.0.0"
14659 + },
14660 + "engines": {
14661 + "node": ">=18"
14662 + },
14663 + "funding": {
14664 + "url": "https://github.com/sponsors/sindresorhus"
14665 + }
14666 + },
14667 "node_modules/vite-plugin-vue-devtools/node_modules/get-stream": {
14606 - "version": "8.0.1",
14607 - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
14608 - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
14668 + "version": "9.0.1",
14669 + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
14670 + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
14671 "dev": true,
14672 "license": "MIT",
14673 + "dependencies": {
14674 + "@sec-ant/readable-stream": "^0.4.1",
14675 + "is-stream": "^4.0.1"
14676 + },
14677 "engines": {
14612 - "node": ">=16"
14678 + "node": ">=18"
14679 },
14680 "funding": {
14681 "url": "https://github.com/sponsors/sindresorhus"
14682 }
14683 },
14684 "node_modules/vite-plugin-vue-devtools/node_modules/human-signals": {
14619 - "version": "5.0.0",
14620 - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
14621 - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
14685 + "version": "8.0.0",
14686 + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz",
14687 + "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==",
14688 "dev": true,
14689 "license": "Apache-2.0",
14690 "engines": {
14625 - "node": ">=16.17.0"
14691 + "node": ">=18.18.0"
14692 }
14693 },
14694 "node_modules/vite-plugin-vue-devtools/node_modules/is-stream": {
14629 - "version": "3.0.0",
14630 - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
14631 - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
14695 + "version": "4.0.1",
14696 + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
14697 + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
14698 "dev": true,
14699 "license": "MIT",
14700 "engines": {
14635 - "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
14701 + "node": ">=18"
14702 },
14703 "funding": {
14704 "url": "https://github.com/sponsors/sindresorhus"
14705 }
14706 },
14641 - "node_modules/vite-plugin-vue-devtools/node_modules/mimic-fn": {
14642 - "version": "4.0.0",
14643 - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
14644 - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
14707 + "node_modules/vite-plugin-vue-devtools/node_modules/is-unicode-supported": {
14708 + "version": "2.1.0",
14709 + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
14710 + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
14711 "dev": true,
14712 "license": "MIT",
14713 "engines": {
14648 - "node": ">=12"
14714 + "node": ">=18"
14715 },
14716 "funding": {
14717 "url": "https://github.com/sponsors/sindresorhus"
14718 }
14719 },
14720 "node_modules/vite-plugin-vue-devtools/node_modules/npm-run-path": {
14655 - "version": "5.3.0",
14656 - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
14657 - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
14658 - "dev": true,
14659 - "license": "MIT",
14660 - "dependencies": {
14661 - "path-key": "^4.0.0"
14662 - },
14663 - "engines": {
14664 - "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
14665 - },
14666 - "funding": {
14667 - "url": "https://github.com/sponsors/sindresorhus"
14668 - }
14669 - },
14670 - "node_modules/vite-plugin-vue-devtools/node_modules/onetime": {
14721 "version": "6.0.0",
14672 - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
14673 - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
14722 + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
14723 + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
14724 "dev": true,
14725 "license": "MIT",
14726 "dependencies": {
14677 - "mimic-fn": "^4.0.0"
14727 + "path-key": "^4.0.0",
14728 + "unicorn-magic": "^0.3.0"
14729 },
14730 "engines": {
14680 - "node": ">=12"
14731 + "node": ">=18"
14732 },
14733 "funding": {
14734 "url": "https://github.com/sponsors/sindresorhus"
@@ -14710,22 +14761,35 @@
14761 }
14762 },
14763 "node_modules/vite-plugin-vue-devtools/node_modules/strip-final-newline": {
14713 - "version": "3.0.0",
14714 - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
14715 - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
14764 + "version": "4.0.0",
14765 + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
14766 + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
14767 "dev": true,
14768 "license": "MIT",
14769 "engines": {
14719 - "node": ">=12"
14770 + "node": ">=18"
14771 + },
14772 + "funding": {
14773 + "url": "https://github.com/sponsors/sindresorhus"
14774 + }
14775 + },
14776 + "node_modules/vite-plugin-vue-devtools/node_modules/unicorn-magic": {
14777 + "version": "0.3.0",
14778 + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
14779 + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
14780 + "dev": true,
14781 + "license": "MIT",
14782 + "engines": {
14783 + "node": ">=18"
14784 },
14785 "funding": {
14786 "url": "https://github.com/sponsors/sindresorhus"
14787 }
14788 },
14789 "node_modules/vite-plugin-vue-inspector": {
14726 - "version": "5.3.0",
14727 - "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.0.tgz",
14728 - "integrity": "sha512-F6JNRUOrZl8FaUCTxPhsOLn2ka7N7Sz9ppxmmEwpybVBDYnhelbNnnlZpeFPc4ULnxbitSi8b0V2C0KT3CjReg==",
14790 + "version": "5.3.1",
14791 + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz",
14792 + "integrity": "sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A==",
14793 "dev": true,
14794 "license": "MIT",
14795 "dependencies": {
@@ -14740,7 +14804,7 @@
14804 "magic-string": "^0.30.4"
14805 },
14806 "peerDependencies": {
14743 - "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0"
14807 + "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
14808 }
14809 },
14810 "node_modules/vite-svg-loader": {
@@ -15403,13 +15467,13 @@
15467 }
15468 },
15469 "node_modules/vue-i18n": {
15406 - "version": "10.0.4",
15407 - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.4.tgz",
15408 - "integrity": "sha512-1xkzVxqBLk2ZFOmeI+B5r1J7aD/WtNJ4j9k2mcFcQo5BnOmHBmD7z4/oZohh96AAaRZ4Q7mNQvxc9h+aT+Md3w==",
15470 + "version": "10.0.5",
15471 + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.5.tgz",
15472 + "integrity": "sha512-9/gmDlCblz3i8ypu/afiIc/SUIfTTE1mr0mZhb9pk70xo2csHAM9mp2gdQ3KD2O0AM3Hz/5ypb+FycTj/lHlPQ==",
15473 "license": "MIT",
15474 "dependencies": {
15411 - "@intlify/core-base": "10.0.4",
15412 - "@intlify/shared": "10.0.4",
15475 + "@intlify/core-base": "10.0.5",
15476 + "@intlify/shared": "10.0.5",
15477 "@vue/devtools-api": "^6.5.0"
15478 },
15479 "engines": {
@@ -15930,6 +15994,19 @@
15994 "url": "https://github.com/sponsors/sindresorhus"
15995 }
15996 },
15997 + "node_modules/yoctocolors": {
15998 + "version": "2.1.1",
15999 + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz",
16000 + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
16001 + "dev": true,
16002 + "license": "MIT",
16003 + "engines": {
16004 + "node": ">=18"
16005 + },
16006 + "funding": {
16007 + "url": "https://github.com/sponsors/sindresorhus"
16008 + }
16009 + },
16010 "node_modules/zrender": {
16011 "version": "5.6.0",
16012 "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz",
frontend/package.json
+11 -11
@@ -41,7 +41,7 @@
41 "@fontsource/jetbrains-mono": "^5.1.1",
42 "@fontsource/lexend": "^5.1.1",
43 "@fontsource/public-sans": "^5.1.1",
44 - "@shikijs/markdown-it": "^1.23.1",
44 + "@shikijs/markdown-it": "^1.24.0",
45 "@tailwindcss/container-queries": "^0.1.1",
46 "@vueuse/core": "^12.0.0",
47 "axios": "^1.7.8",
@@ -59,15 +59,15 @@
59 "naive-ui": "^2.40.2",
60 "nanoid": "^5.0.9",
61 "password-validator": "^5.3.0",
62 - "pinia": "^2.2.7",
62 + "pinia": "^2.2.8",
63 "pinia-plugin-persistedstate": "^4.1.3",
64 "secure-ls": "^2.0.0",
65 - "shiki": "^1.23.1",
65 + "shiki": "^1.24.0",
66 "validator": "^13.12.0",
67 "vue": "^3.5.13",
68 "vue-advanced-cropper": "^2.8.9",
69 "vue-highlight-words": "^3.0.1",
70 - "vue-i18n": "^10.0.4",
70 + "vue-i18n": "^10.0.5",
71 "vue-router": "^4.5.0",
72 "vue-sjv": "^0.0.6",
73 "vue3-apexcharts": "^1.8.0",
@@ -78,7 +78,7 @@
78 "@rollup/rollup-linux-x64-gnu": "^4.27.4"
79 },
80 "devDependencies": {
81 - "@antfu/eslint-config": "^3.11.0",
81 + "@antfu/eslint-config": "^3.11.2",
82 "@clack/prompts": "^0.8.2",
83 "@iconify/vue": "^4.1.2",
84 "@tsconfig/node20": "^20.1.4",
@@ -87,16 +87,16 @@
87 "@types/fs-extra": "^11.0.4",
88 "@types/jsdom": "^21.1.7",
89 "@types/lodash": "^4.17.13",
90 - "@types/node": "^22.10.0",
90 + "@types/node": "^22.10.1",
91 "@types/validator": "^13.12.2",
92 "@vitejs/plugin-vue": "^5.2.1",
93 "@vitejs/plugin-vue-jsx": "^4.1.1",
94 "@vue/test-utils": "^2.4.6",
95 - "@vue/tsconfig": "^0.6.0",
95 + "@vue/tsconfig": "^0.7.0",
96 "autoprefixer": "^10.4.20",
97 "cypress": "^13.16.0",
98 "depcheck": "^1.4.7",
99 - "eslint": "^9.15.0",
99 + "eslint": "^9.16.0",
100 "flourite": "^1.3.0",
101 "fs-extra": "^11.2.0",
102 "jsdom": "^25.0.1",
@@ -114,7 +114,7 @@
114 "unplugin-vue-components": "^0.27.5",
115 "vite": "^5.4.11",
116 "vite-bundle-visualizer": "^1.2.1",
117 - "vite-plugin-vue-devtools": "^7.6.5",
117 + "vite-plugin-vue-devtools": "^7.6.7",
118 "vite-svg-loader": "^5.1.0",
119 "vitest": "^2.1.6",
120 "vue-tsc": "^2.1.10"
@@ -130,10 +130,10 @@
130 },
131 "overrides": {
132 "@typescript-eslint/eslint-plugin": {
133 - "eslint": "^9.15.0"
133 + "eslint": "^9.16.0"
134 },
135 "@typescript-eslint/parser": {
136 - "eslint": "^9.15.0"
136 + "eslint": "^9.16.0"
137 },
138 "@typescript-eslint/typescript-estree": "^8.16.0"
139 }
frontend/src/api/endpoints/threatIntel.ts
+7 -1
@@ -4,7 +4,8 @@ import type {
4 AiWazuhExclusionRuleResponse,
5 EpssScore,
6 EvaluationData,
7 - ThreatIntelResponse
7 + ThreatIntelResponse,
8 + VirusTotalResponse
9 } from "@/types/threatIntel.d"
10 import { HttpClient } from "../httpClient"
11
@@ -44,5 +45,10 @@ export default {
45 index_id: indexId
46 }
47 )
48 + },
49 + virusTotalEnrichment(iocValue: string) {
50 + return HttpClient.post<FlaskBaseResponse & VirusTotalResponse>(`/threat_intel/virustotal`, {
51 + ioc_value: iocValue
52 + })
53 }
54 }
frontend/src/assets/scss/index.scss
+1 -1
@@ -18,7 +18,7 @@ body {
18 -moz-osx-font-smoothing: grayscale;
19 text-rendering: optimizeLegibility;
20 line-height: 1.35;
21 - font-size: 15px;
21 + font-size: 16px;
22 text-wrap: pretty;
23
24 margin: 0;
frontend/src/components/agents/vulnerabilities/VulnerabilityCard.vue
+5 -1
@@ -86,7 +86,7 @@
86 <n-tab-pane name="External references" tab="External references" display-directive="show">
87 <div class="p-7 pt-2">
88 <ul>
89 - <li v-for="refItem of vulnerability.external_references" :key="refItem">
89 + <li v-for="refItem of externalReferences" :key="refItem">
90 <a :href="refItem" target="_blank">{{ refItem }}</a>
91 </li>
92 </ul>
@@ -109,6 +109,7 @@ import Icon from "@/components/common/Icon.vue"
109 import { useSettingsStore } from "@/stores/settings"
110 import dayjs from "@/utils/dayjs"
111 import { cloneDeep } from "lodash"
112 +import _split from "lodash/split"
113 import _truncate from "lodash/truncate"
114 import { NModal, NTabPane, NTabs, NTooltip } from "naive-ui"
115 import { computed, defineAsyncComponent, ref, toRefs } from "vue"
@@ -129,6 +130,9 @@ const dFormats = useSettingsStore().dateFormat
130
131 const title = computed(() => _truncate(vulnerability.value.title, { length: 20 }))
132 const extract = computed(() => _truncate(vulnerability.value.title, { length: 50 }))
133 +const externalReferences = computed(() =>
134 + (vulnerability.value.external_references || []).map(ref => _split(ref, ",")).flat()
135 +)
136
137 const vulnerabilitySanitized = computed(() => {
138 const newObj = []
frontend/src/components/common/cards/CardKV.vue
+3 -2
@@ -3,16 +3,17 @@
3 <div v-if="$slots.key" class="key">
4 <slot name="key"></slot>
5 </div>
6 - <div v-if="$slots.value" class="value">
6 + <div v-if="$slots.value" class="value" :class="valueClass">
7 <slot name="value"></slot>
8 </div>
9 </div>
10 </template>
11
12 <script setup lang="ts">
13 -const { color } = defineProps<{
13 +const { color, size, valueClass } = defineProps<{
14 color?: "danger" | "warning" | "success" | "primary"
15 size?: "lg"
16 + valueClass?: string
17 }>()
18 </script>
19
frontend/src/components/common/cards/CardStatsBars.vue
+18 -8
@@ -24,7 +24,7 @@
24 </div>
25 <div class="list flex flex-col">
26 <div
27 - v-for="item of sanitizedValues"
27 + v-for="item of listValues"
28 :key="JSON.stringify(item)"
29 class="item flex items-center gap-3"
30 :class="item.status"
@@ -49,7 +49,7 @@
49 import Icon from "@/components/common/Icon.vue"
50 import _round from "lodash/round"
51 import { NCard } from "naive-ui"
52 -import { computed, toRefs } from "vue"
52 +import { computed } from "vue"
53
54 export interface ItemProps {
55 value: number
@@ -62,19 +62,25 @@ interface ItemPropsExt extends ItemProps {
62 percentage: number
63 }
64
65 -const props = defineProps<{
65 +const {
66 + title,
67 + values,
68 + showTotal = true,
69 + showZeroItems,
70 + hovered
71 +} = defineProps<{
72 title: string
73 values: ItemProps[]
74 + showTotal?: boolean
75 showZeroItems?: boolean
76 hovered?: boolean
77 }>()
71 -const { title, values, showZeroItems, hovered } = toRefs(props)
78
79 const ArrowRightIcon = "carbon:arrow-right"
80 const totItem = computed(
81 () =>
76 - values.value.find(item => item.isTotal) || {
77 - value: values.value.reduce((acc, cur) => {
82 + values.find(item => item.isTotal) || {
83 + value: values.reduce((acc, cur) => {
84 return acc + cur.value
85 }, 0),
86 isTotal: true,
@@ -83,14 +89,18 @@ const totItem = computed(
89 )
90
91 const sanitizedValues = computed<ItemPropsExt[]>(() => {
86 - const list: ItemPropsExt[] = values.value
92 + const list: ItemPropsExt[] = values
93 .filter(o => !o.isTotal)
94 .map(o => ({ ...o, percentage: _round((o.value / totItem.value.value) * 100, 2) }))
89 - .filter(o => o.value || (!o.value && showZeroItems.value))
95 + .filter(o => o.value || (!o.value && showZeroItems))
96
97 return [{ ...totItem.value, percentage: 100 }, ...list]
98 })
99
100 +const listValues = computed<ItemPropsExt[]>(() =>
101 + sanitizedValues.value.filter(o => !o.isTotal || (showTotal && o.isTotal))
102 +)
103 +
104 const barValues = computed<ItemPropsExt[]>(() => sanitizedValues.value.filter(o => !o.isTotal && o.percentage))
105 </script>
106
frontend/src/components/incidentManagement/alerts/AlertIoCItem.vue
+19 -14
@@ -8,20 +8,24 @@
8 <p class="mt-2">{{ ioc.description }}</p>
9 </template>
10 <template #footerExtra>
11 - <n-popconfirm
12 - v-model:show="showDeleteConfirm"
13 - trigger="manual"
14 - to="body"
15 - @positive-click="deleteIoc()"
16 - @clickoutside="showDeleteConfirm = false"
17 - >
18 - <template #trigger>
19 - <n-button quaternary size="tiny" :loading="canceling" @click.stop="showDeleteConfirm = true">
20 - Delete
21 - </n-button>
22 - </template>
23 - Are you sure you want to delete this IoC?
24 - </n-popconfirm>
11 + <div class="flex items-center justify-end gap-3">
12 + <VirusTotalEnrichmentButton :ioc-value="ioc.value" />
13 +
14 + <n-popconfirm
15 + v-model:show="showDeleteConfirm"
16 + trigger="manual"
17 + to="body"
18 + @positive-click="deleteIoc()"
19 + @clickoutside="showDeleteConfirm = false"
20 + >
21 + <template #trigger>
22 + <n-button quaternary size="tiny" :loading="canceling" @click.stop="showDeleteConfirm = true">
23 + Delete
24 + </n-button>
25 + </template>
26 + Are you sure you want to delete this IoC?
27 + </n-popconfirm>
28 + </div>
29 </template>
30 </CardEntity>
31 </template>
@@ -30,6 +34,7 @@
34 import type { AlertIOC } from "@/types/incidentManagement/alerts"
35 import Api from "@/api"
36 import CardEntity from "@/components/common/cards/CardEntity.vue"
37 +import VirusTotalEnrichmentButton from "@/components/threatIntel/VirusTotalEnrichmentButton.vue"
38 import { NButton, NPopconfirm, useMessage } from "naive-ui"
39 import { ref } from "vue"
40
frontend/src/components/threatIntel/VirusTotalEnrichmentButton.vue new
+641
@@ -0,0 +1,641 @@
1 +<template>
2 + <div>
3 + <n-button :size="size || 'small'" ghost type="primary" :loading @click="analysis()">
4 + <template #icon>
5 + <Icon :name="AiIcon" />
6 + </template>
7 + <div class="flex items-center gap-2">
8 + <span>Enrich with VirusTotal</span>
9 + </div>
10 + </n-button>
11 +
12 + <n-modal
13 + v-model:show="showModal"
14 + preset="card"
15 + content-class="!p-0"
16 + :style="{ maxWidth: 'min(710px, 90vw)', minHeight: 'min(500px, 90vh)' }"
17 + :bordered="false"
18 + segmented
19 + title="Virus Total Enrichment"
20 + >
21 + <n-tabs v-if="virusTotalDataResponse" type="line" animated :tabs-padding="24">
22 + <n-tab-pane name="Overview" tab="Overview" display-directive="show">
23 + <div class="flex flex-col gap-4 p-7 pt-2">
24 + <div class="grid-auto-fit-200 grid gap-4">
25 + <CardKV>
26 + <template #key>type</template>
27 + <template #value>{{ virusTotalDataResponse.type }}</template>
28 + </CardKV>
29 + <CardKV>
30 + <template #key>id</template>
31 + <template #value>{{ virusTotalDataResponse.id }}</template>
32 + </CardKV>
33 + </div>
34 + <CardStatsBars
35 + title="Total votes"
36 + class="h-full cursor-pointer"
37 + :values="totalVotes"
38 + :show-total="false"
39 + ></CardStatsBars>
40 + <CardStatsBars
41 + title="Last Analysis Stats"
42 + class="h-full cursor-pointer"
43 + :values="lastAnalysisStats"
44 + :show-total="false"
45 + show-zero-items
46 + ></CardStatsBars>
47 + <div
48 + v-if="virusTotalDataResponse.attributes?.tags?.length"
49 + class="text-secondary flex flex-wrap gap-4 text-sm"
50 + >
51 + <span v-for="tag of virusTotalDataResponse.attributes.tags" :key="tag">#{{ tag }}</span>
52 + </div>
53 + <ul v-if="Object.keys(virusTotalDataResponse.links || {}).length">
54 + <li v-for="link of virusTotalDataResponse.links" :key="link">
55 + <a :href="link" target="_blank">{{ link }}</a>
56 + </li>
57 + </ul>
58 + </div>
59 + </n-tab-pane>
60 + <n-tab-pane name="Analysis Results" tab="Analysis Results" display-directive="show">
61 + <div class="p-7 pt-2">
62 + <div
63 + v-if="Object.keys(virusTotalDataResponse.attributes?.last_analysis_results || {}).length"
64 + class="flex flex-col gap-3"
65 + >
66 + <div
67 + v-if="virusTotalDataResponse.attributes.last_analysis_date"
68 + class="text-right font-mono text-sm"
69 + >
70 + last analysis date:
71 + <code>
72 + {{
73 + formatDate(
74 + virusTotalDataResponse.attributes.last_analysis_date,
75 + dFormats.datetime
76 + )
77 + }}
78 + </code>
79 + </div>
80 + <CardEntity
81 + v-for="(value, key) of virusTotalDataResponse.attributes.last_analysis_results"
82 + :key
83 + embedded
84 + class="@container"
85 + >
86 + <template #header>{{ key }}</template>
87 + <template #footer>
88 + <div class="@xs:!flex-row flex flex-col justify-between gap-4 text-sm">
89 + <n-statistic class="grow">
90 + <template #label>
91 + <span class="text-sm">method</span>
92 + </template>
93 + <span class="text-base">{{ value.method }}</span>
94 + </n-statistic>
95 + <n-statistic class="grow">
96 + <template #label>
97 + <span class="text-sm">category</span>
98 + </template>
99 + <span class="text-base">{{ value.category }}</span>
100 + </n-statistic>
101 + <n-statistic class="grow">
102 + <template #label>
103 + <span class="text-sm">result</span>
104 + </template>
105 + <span class="text-base">{{ value.result }}</span>
106 + </n-statistic>
107 + </div>
108 + </template>
109 + </CardEntity>
110 + </div>
111 + <n-empty v-else description="No items found" class="h-48 justify-center" />
112 + </div>
113 + </n-tab-pane>
114 + <n-tab-pane name="Details" tab="Details" display-directive="show">
115 + <div class="p-7 pt-4">
116 + <CodeSource :code="properties" :decode="true" />
117 + </div>
118 + </n-tab-pane>
119 + <n-tab-pane
120 + v-if="virusTotalDataResponse.attributes.whois"
121 + name="Whois"
122 + tab="Whois"
123 + display-directive="show"
124 + >
125 + <div class="p-7 pt-4">
126 + <div
127 + v-if="virusTotalDataResponse.attributes.whois_date"
128 + class="mb-4 text-right font-mono text-sm"
129 + >
130 + whois date:
131 + <code>
132 + {{ formatDate(virusTotalDataResponse.attributes.whois_date, dFormats.datetime) }}
133 + </code>
134 + </div>
135 + <n-input
136 + :value="virusTotalDataResponse.attributes.whois"
137 + type="textarea"
138 + readonly
139 + placeholder="Empty"
140 + size="large"
141 + :autosize="{
142 + minRows: 3,
143 + maxRows: 18
144 + }"
145 + />
146 + </div>
147 + </n-tab-pane>
148 + <n-tab-pane name="HTTPS Certificate" tab="HTTPS Certificate" display-directive="show">
149 + <div class="flex flex-col gap-3 p-7 pt-2">
150 + <div
151 + v-if="virusTotalDataResponse.attributes.last_https_certificate_date"
152 + class="text-right font-mono text-sm"
153 + >
154 + last https certificate date:
155 + <code>
156 + {{
157 + formatDate(
158 + virusTotalDataResponse.attributes.last_https_certificate_date,
159 + dFormats.datetime
160 + )
161 + }}
162 + </code>
163 + </div>
164 +
165 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
166 + <template #key>cert_signature</template>
167 + <template #value>
168 + <n-table
169 + v-if="
170 + Object.keys(
171 + virusTotalDataResponse.attributes?.last_https_certificate.cert_signature
172 + ).length
173 + "
174 + :bordered="false"
175 + single-line
176 + >
177 + <tbody>
178 + <tr
179 + v-for="(value, key) of virusTotalDataResponse.attributes
180 + .last_https_certificate.cert_signature"
181 + :key
182 + >
183 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
184 + <td class="whitespace-nowrap text-sm font-semibold">{{ value }}</td>
185 + </tr>
186 + </tbody>
187 + </n-table>
188 + </template>
189 + </CardKV>
190 +
191 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
192 + <template #key>authority_key_identifier</template>
193 + <template #value>
194 + <n-table
195 + v-if="
196 + Object.keys(
197 + virusTotalDataResponse.attributes?.last_https_certificate?.extensions
198 + ?.authority_key_identifier || {}
199 + ).length
200 + "
201 + :bordered="false"
202 + single-line
203 + >
204 + <tbody>
205 + <tr
206 + v-for="(value, key) of virusTotalDataResponse.attributes
207 + .last_https_certificate.extensions.authority_key_identifier"
208 + :key
209 + >
210 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
211 + <td class="whitespace-nowrap text-sm font-semibold">{{ value }}</td>
212 + </tr>
213 + </tbody>
214 + </n-table>
215 + </template>
216 + </CardKV>
217 +
218 + <CardKV>
219 + <template #key>subject_key_identifier</template>
220 + <template #value>
221 + {{
222 + virusTotalDataResponse.attributes.last_https_certificate?.extensions
223 + ?.subject_key_identifier || "-"
224 + }}
225 + </template>
226 + </CardKV>
227 +
228 + <CardKV>
229 + <template #key>subject_alternative_name</template>
230 + <template #value>
231 + <span
232 + v-if="
233 + !virusTotalDataResponse.attributes.last_https_certificate?.extensions
234 + ?.subject_alternative_name?.length
235 + "
236 + >
237 + -
238 + </span>
239 + <div v-else class="flex flex-wrap gap-2">
240 + <Badge
241 + v-for="value of virusTotalDataResponse.attributes.last_https_certificate
242 + ?.extensions?.subject_alternative_name"
243 + :key="value"
244 + >
245 + <template #value>
246 + {{ value }}
247 + </template>
248 + </Badge>
249 + </div>
250 + </template>
251 + </CardKV>
252 +
253 + <CardKV>
254 + <template #key>certificate_policies</template>
255 + <template #value>
256 + <span
257 + v-if="
258 + !virusTotalDataResponse.attributes.last_https_certificate?.extensions
259 + ?.certificate_policies?.length
260 + "
261 + >
262 + -
263 + </span>
264 + <div v-else class="flex flex-wrap gap-2">
265 + <Badge
266 + v-for="value of virusTotalDataResponse.attributes.last_https_certificate
267 + ?.extensions?.certificate_policies"
268 + :key="value"
269 + >
270 + <template #value>
271 + {{ value }}
272 + </template>
273 + </Badge>
274 + </div>
275 + </template>
276 + </CardKV>
277 +
278 + <CardKV>
279 + <template #key>key_usage</template>
280 + <template #value>
281 + <span
282 + v-if="
283 + !virusTotalDataResponse.attributes.last_https_certificate?.extensions?.key_usage
284 + ?.length
285 + "
286 + >
287 + -
288 + </span>
289 + <div v-else class="flex flex-wrap gap-2">
290 + <Badge
291 + v-for="value of virusTotalDataResponse.attributes.last_https_certificate
292 + ?.extensions?.key_usage"
293 + :key="value"
294 + >
295 + <template #value>
296 + {{ value }}
297 + </template>
298 + </Badge>
299 + </div>
300 + </template>
301 + </CardKV>
302 +
303 + <CardKV>
304 + <template #key>extended_key_usage</template>
305 + <template #value>
306 + <span
307 + v-if="
308 + !virusTotalDataResponse.attributes.last_https_certificate?.extensions
309 + ?.extended_key_usage?.length
310 + "
311 + >
312 + -
313 + </span>
314 + <div v-else class="flex flex-wrap gap-2">
315 + <Badge
316 + v-for="value of virusTotalDataResponse.attributes.last_https_certificate
317 + ?.extensions?.extended_key_usage"
318 + :key="value"
319 + >
320 + <template #value>
321 + {{ value }}
322 + </template>
323 + </Badge>
324 + </div>
325 + </template>
326 + </CardKV>
327 +
328 + <CardKV>
329 + <template #key>crl_distribution_points</template>
330 + <template #value>
331 + <span
332 + v-if="
333 + !virusTotalDataResponse.attributes.last_https_certificate?.extensions
334 + ?.crl_distribution_points?.length
335 + "
336 + >
337 + -
338 + </span>
339 + <ul v-else class="flex flex-wrap gap-2">
340 + <li
341 + v-for="value of virusTotalDataResponse.attributes.last_https_certificate
342 + ?.extensions?.crl_distribution_points"
343 + :key="value"
344 + >
345 + <a :href="value" target="_blank" rel="nofollow noopener noreferrer">
346 + {{ value }}
347 + </a>
348 + </li>
349 + </ul>
350 + </template>
351 + </CardKV>
352 +
353 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
354 + <template #key>ca_information_access</template>
355 + <template #value>
356 + <n-table
357 + v-if="
358 + Object.keys(
359 + virusTotalDataResponse.attributes?.last_https_certificate?.extensions
360 + ?.ca_information_access || {}
361 + ).length
362 + "
363 + :bordered="false"
364 + single-line
365 + >
366 + <tbody>
367 + <tr
368 + v-for="(value, key) of virusTotalDataResponse.attributes
369 + .last_https_certificate.extensions.ca_information_access"
370 + :key
371 + >
372 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
373 + <td class="whitespace-nowrap text-sm font-semibold">
374 + <a :href="value" target="_blank" rel="nofollow noopener noreferrer">
375 + {{ value }}
376 + </a>
377 + </td>
378 + </tr>
379 + </tbody>
380 + </n-table>
381 + </template>
382 + </CardKV>
383 +
384 + <div class="flex gap-3">
385 + <CardKV class="grow">
386 + <template #key>CA</template>
387 + <template #value>
388 + {{ virusTotalDataResponse.attributes.last_https_certificate.extensions.CA }}
389 + </template>
390 + </CardKV>
391 + <CardKV class="grow">
392 + <template #key>size</template>
393 + <template #value>
394 + {{ virusTotalDataResponse.attributes.last_https_certificate.size }}
395 + </template>
396 + </CardKV>
397 + <CardKV class="grow">
398 + <template #key>version</template>
399 + <template #value>
400 + {{ virusTotalDataResponse.attributes.last_https_certificate.version }}
401 + </template>
402 + </CardKV>
403 + </div>
404 +
405 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
406 + <template #key>validity</template>
407 + <template #value>
408 + <n-table
409 + v-if="
410 + Object.keys(
411 + virusTotalDataResponse.attributes?.last_https_certificate?.validity || {}
412 + ).length
413 + "
414 + :bordered="false"
415 + single-line
416 + >
417 + <tbody>
418 + <tr
419 + v-for="(value, key) of virusTotalDataResponse.attributes
420 + .last_https_certificate.validity"
421 + :key
422 + >
423 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
424 + <td class="whitespace-nowrap text-sm font-semibold">{{ value }}</td>
425 + </tr>
426 + </tbody>
427 + </n-table>
428 + </template>
429 + </CardKV>
430 +
431 + <CardKV>
432 + <template #key>public_key</template>
433 + <template #value>
434 + <CodeSource
435 + :code="virusTotalDataResponse.attributes.last_https_certificate.public_key"
436 + :decode="true"
437 + />
438 + </template>
439 + </CardKV>
440 +
441 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
442 + <template #value>
443 + <n-table :bordered="false" single-line>
444 + <tbody>
445 + <tr>
446 + <td class="whitespace-nowrap text-sm">thumbprint_sha256</td>
447 + <td class="whitespace-nowrap text-sm font-semibold">
448 + {{
449 + virusTotalDataResponse.attributes.last_https_certificate
450 + .thumbprint_sha256
451 + }}
452 + </td>
453 + </tr>
454 + <tr>
455 + <td class="whitespace-nowrap text-sm">thumbprint</td>
456 + <td class="whitespace-nowrap text-sm font-semibold">
457 + {{
458 + virusTotalDataResponse.attributes.last_https_certificate.thumbprint
459 + }}
460 + </td>
461 + </tr>
462 + <tr>
463 + <td class="whitespace-nowrap text-sm">serial_number</td>
464 + <td class="whitespace-nowrap text-sm font-semibold">
465 + {{
466 + virusTotalDataResponse.attributes.last_https_certificate
467 + .serial_number
468 + }}
469 + </td>
470 + </tr>
471 + </tbody>
472 + </n-table>
473 + </template>
474 + </CardKV>
475 +
476 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
477 + <template #key>issuer</template>
478 + <template #value>
479 + <n-table
480 + v-if="
481 + Object.keys(
482 + virusTotalDataResponse.attributes?.last_https_certificate?.issuer || {}
483 + ).length
484 + "
485 + :bordered="false"
486 + single-line
487 + >
488 + <tbody>
489 + <tr
490 + v-for="(value, key) of virusTotalDataResponse.attributes
491 + .last_https_certificate.issuer"
492 + :key
493 + >
494 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
495 + <td class="whitespace-nowrap text-sm font-semibold">{{ value }}</td>
496 + </tr>
497 + </tbody>
498 + </n-table>
499 + </template>
500 + </CardKV>
501 +
502 + <CardKV value-class="!p-0 overflow-x-auto overflow-y-hidden">
503 + <template #key>subject</template>
504 + <template #value>
505 + <n-table
506 + v-if="
507 + Object.keys(
508 + virusTotalDataResponse.attributes?.last_https_certificate?.subject || {}
509 + ).length
510 + "
511 + :bordered="false"
512 + single-line
513 + >
514 + <tbody>
515 + <tr
516 + v-for="(value, key) of virusTotalDataResponse.attributes
517 + .last_https_certificate.subject"
518 + :key
519 + >
520 + <td class="whitespace-nowrap text-sm">{{ key }}</td>
521 + <td class="whitespace-nowrap text-sm font-semibold">{{ value }}</td>
522 + </tr>
523 + </tbody>
524 + </n-table>
525 + </template>
526 + </CardKV>
527 + </div>
528 + </n-tab-pane>
529 + </n-tabs>
530 + </n-modal>
531 + </div>
532 +</template>
533 +
534 +<script setup lang="ts">
535 +import type { ItemProps } from "@/components/common/cards/CardStatsBars.vue"
536 +import type { VirusTotalData } from "@/types/threatIntel.d"
537 +import type { Size } from "naive-ui/es/button/src/interface"
538 +import Api from "@/api"
539 +import Icon from "@/components/common/Icon.vue"
540 +import { useSettingsStore } from "@/stores/settings"
541 +import { formatDate } from "@/utils"
542 +import _pick from "lodash/pick"
543 +import { NButton, NEmpty, NInput, NModal, NStatistic, NTable, NTabPane, NTabs, useMessage } from "naive-ui"
544 +import { computed, defineAsyncComponent, ref } from "vue"
545 +
546 +const { iocValue, size } = defineProps<{
547 + iocValue: string
548 + size?: Size
549 +}>()
550 +
551 +const Badge = defineAsyncComponent(() => import("@/components/common/Badge.vue"))
552 +const CardKV = defineAsyncComponent(() => import("@/components/common/cards/CardKV.vue"))
553 +const CardEntity = defineAsyncComponent(() => import("@/components/common/cards/CardEntity.vue"))
554 +const CardStatsBars = defineAsyncComponent(() => import("@/components/common/cards/CardStatsBars.vue"))
555 +const CodeSource = defineAsyncComponent(() => import("@/components/common/CodeSource.vue"))
556 +
557 +const AiIcon = "mage:stars-c"
558 +const showModal = ref<boolean>(false)
559 +const loading = ref<boolean>(false)
560 +const dFormats = useSettingsStore().dateFormat
561 +const message = useMessage()
562 +const virusTotalDataResponse = ref<VirusTotalData | null>(null)
563 +const totalVotes = computed<ItemProps[]>(() =>
564 + Object.entries(virusTotalDataResponse.value?.attributes.total_votes || {})
565 + .map(([key, value]) => ({
566 + value,
567 + label: key,
568 + status: getStatusByCategory(key)
569 + }))
570 + .sort((a, b) => a.value - b.value)
571 +)
572 +const lastAnalysisStats = computed<ItemProps[]>(() =>
573 + Object.entries(virusTotalDataResponse.value?.attributes.last_analysis_stats || {})
574 + .map(([key, value]) => ({
575 + value,
576 + label: key,
577 + status: getStatusByCategory(key)
578 + }))
579 + .sort((a, b) => a.value - b.value)
580 +)
581 +const properties = computed(() => {
582 + return _pick(virusTotalDataResponse.value?.attributes || {}, [
583 + "regional_internet_registry",
584 + "continent",
585 + "last_modification_date",
586 + "crowdsourced_context",
587 + "asn",
588 + "reputation",
589 + "jarm",
590 + "country",
591 + "as_owner",
592 + "network"
593 + ])
594 +})
595 +
596 +function getStatusByCategory(category: string) {
597 + let status: "success" | "warning" | "error" | "muted" | "primary" = "muted"
598 +
599 + switch (category) {
600 + case "harmless":
601 + status = "success"
602 + break
603 + case "malicious":
604 + status = "error"
605 + break
606 + case "suspicious":
607 + status = "warning"
608 + break
609 + case "undetected":
610 + case "timeout":
611 + status = "muted"
612 + break
613 + }
614 + return status
615 +}
616 +
617 +function openResponse() {
618 + showModal.value = true
619 +}
620 +
621 +function analysis() {
622 + loading.value = true
623 +
624 + Api.threatIntel
625 + .virusTotalEnrichment(iocValue)
626 + .then(res => {
627 + if (res.data.success) {
628 + virusTotalDataResponse.value = res.data.data.data
629 + openResponse()
630 + } else {
631 + message.warning(res.data?.message || "An error occurred. Please try again later.")
632 + }
633 + })
634 + .catch(err => {
635 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
636 + })
637 + .finally(() => {
638 + loading.value = false
639 + })
640 +}
641 +</script>
frontend/src/types/threatIntel.d.ts
+106
@@ -86,3 +86,109 @@ export interface AiWazuhExclusionRuleResponse {
86 wazuh_exclusion_rule: string
87 wazuh_exclusion_rule_justification: string
88 }
89 +
90 +export interface VirusTotalResponse {
91 + data: VirusTotal
92 +}
93 +
94 +export interface VirusTotal {
95 + data: VirusTotalData
96 +}
97 +
98 +export interface VirusTotalData {
99 + id: string
100 + type: string
101 + links: { [key: string]: string }
102 + attributes: VirusTotalAttributes
103 +}
104 +
105 +export interface VirusTotalAttributes {
106 + total_votes: { [key in VirusTotalLastAnalysisResultCategory]: number }
107 + last_analysis_results: { [key: string]: VirusTotalLastAnalysisResult }
108 + regional_internet_registry: string | null
109 + continent: string | null
110 + last_modification_date: number
111 + crowdsourced_context: string | null
112 + tags: string[]
113 + asn: number
114 + whois: string
115 + whois_date: number
116 + reputation: number
117 + last_analysis_date: number
118 + jarm: string
119 + country: string | null
120 + as_owner: string
121 + last_analysis_stats: VirusTotalLastAnalysisStats
122 + last_https_certificate_date: number
123 + network: string
124 + last_https_certificate: VirusTotalLastHTTPSCertificate
125 +}
126 +
127 +export interface VirusTotalLastAnalysisResult {
128 + method: string
129 + engine_name: string
130 + category: VirusTotalLastAnalysisResultCategory
131 + result: VirusTotalLastAnalysisResultResult
132 +}
133 +
134 +export enum VirusTotalLastAnalysisResultCategory {
135 + Harmless = "harmless",
136 + Malicious = "malicious",
137 + Suspicious = "suspicious",
138 + Undetected = "undetected",
139 + Timeout = "timeout"
140 +}
141 +
142 +export enum VirusTotalLastAnalysisResultResult {
143 + Clean = "clean",
144 + Malicious = "malicious",
145 + Suspicious = "suspicious",
146 + Unrated = "unrated"
147 +}
148 +
149 +export interface VirusTotalLastAnalysisStats {
150 + malicious: number
151 + suspicious: number
152 + undetected: number
153 + harmless: number
154 + timeout: number
155 +}
156 +
157 +export interface VirusTotalLastHTTPSCertificate {
158 + cert_signature: {
159 + signature_algorithm: string
160 + signature: string
161 + }
162 + extensions: VirusTotalLastHTTPSCertificateExtensions
163 + validity: {
164 + not_after: Date
165 + not_before: Date
166 + }
167 + size: number
168 + version: string
169 + public_key: {
170 + algorithm: string
171 + ec: {
172 + oid: string
173 + pub: string
174 + }
175 + }
176 + thumbprint_sha256: string
177 + thumbprint: string
178 + serial_number: string
179 + issuer: { [key: string]: string }
180 + subject: { [key: string]: string }
181 +}
182 +
183 +export interface VirusTotalLastHTTPSCertificateExtensions {
184 + authority_key_identifier: { [key: string]: string }
185 + subject_key_identifier: string
186 + subject_alternative_name: string[]
187 + certificate_policies: string[]
188 + key_usage: string[]
189 + extended_key_usage: string[]
190 + crl_distribution_points: string[]
191 + ca_information_access: { [key: string]: string }
192 + CA: boolean
193 + "1.3.6.1.4.1.11129.2.4.2": string
194 +}
frontend/src/views/agents/Overview.vue
+7 -19
@@ -37,12 +37,13 @@
37 {{ agent?.hostname }}
38 </h1>
39
40 - <span v-if="isOnline" class="online-badge">ONLINE</span>
41 -
42 - <span v-if="isQuarantined" class="quarantined-badge flex items-center gap-1">
43 - <Icon :name="QuarantinedIcon" :size="15"></Icon>
40 + <n-tag v-if="isOnline" type="success" round :bordered="false">ONLINE</n-tag>
41 + <n-tag v-if="isQuarantined" type="warning" round :bordered="false">
42 + <template #icon>
43 + <Icon :name="QuarantinedIcon"></Icon>
44 + </template>
45 <span>QUARANTINED</span>
45 - </span>
46 + </n-tag>
47 </div>
48 <div class="label text-secondary mt-2">Agent #{{ agent?.agent_id }}</div>
49 </div>
@@ -147,7 +148,7 @@ import CardEntity from "@/components/common/cards/CardEntity.vue"
148 import Icon from "@/components/common/Icon.vue"
149 import { useGoto } from "@/composables/useGoto"
150 import { type Agent, AgentStatus } from "@/types/agents.d"
150 -import { NButton, NCard, NSpin, NTabPane, NTabs, NTooltip, useDialog, useMessage } from "naive-ui"
151 +import { NButton, NCard, NSpin, NTabPane, NTabs, NTag, NTooltip, useDialog, useMessage } from "naive-ui"
152 import { computed, defineAsyncComponent, nextTick, onBeforeMount, ref } from "vue"
153 import { useRoute, useRouter } from "vue-router"
154
@@ -334,19 +335,6 @@ onBeforeMount(() => {
335 display: flex;
336 align-items: center;
337 }
337 - .online-badge,
338 - .quarantined-badge {
339 - border: 2px solid var(--success-color);
340 - color: var(--success-color);
341 - font-weight: bold;
342 - border-radius: var(--border-radius);
343 - @apply px-2 py-1 text-xs;
344 - }
345 -
346 - .quarantined-badge {
347 - border-color: var(--warning-color);
348 - color: var(--warning-color);
349 - }
338 }
339
340 &.critical {