@cryptotaxi247 / CoPilot / commits / 943074cb

618 patch tuesday report (#682)

* Add Microsoft Patch Tuesday integration with API routes and data handling - Introduced new routes for Microsoft Patch Tuesday data in the FastAPI application. - Implemented endpoints to fetch full Patch Tuesday data, summaries, available cycles, and CVE searches. - Created schemas for request and response models to structure the data. - Developed services to fetch and process data from Microsoft Security Response Center and CISA KEV. - Integrated logging for better traceability of data fetching and processing. * feat: add Patch Tuesday API endpoints and types for data retrieval * feat: Add Microsoft Patch Tuesday feature with detailed overview and filtering - Introduced new components for Patch Tuesday including PatchTuesdayList, PatchTuesdayCard, PatchTuesdayDetail, PatchTuesdayFilters, PatchTuesdayStats, and PatchTuesdayPriorityBadge. - Implemented routing for Patch Tuesday overview page. - Added filtering options for cycle, priority, family, severity, and search functionality. - Created detailed view for each vulnerability with risk scores, prioritization, affected products, and remediation details. - Integrated statistics display for unique CVEs and priority breakdowns. - Updated Navbar to include a link to the Patch Tuesday overview. - Enhanced styling and layout for better user experience. * precommit-fixes * lint fix * chore: update CURRENT_VERSION to 0.1.40

taylor_socfortress committed Feb 5, 2026 at 17:19 UTC 943074cb893095c7262e77ac3a305b0c02daa306
20 files changed +2865 -2
backend/app/integrations/microsoft_patch_tuesday/routes/microsoft_patch_tuesday.py new
+269
@@ -0,0 +1,269 @@
1 +from typing import List
2 +from typing import Optional
3 +
4 +from fastapi import APIRouter
5 +from fastapi import Query
6 +from loguru import logger
7 +
8 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
9 + AvailableCyclesResponse,
10 +)
11 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
12 + PatchTuesdayRequest,
13 +)
14 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
15 + PatchTuesdayResponse,
16 +)
17 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
18 + PatchTuesdaySummaryResponse,
19 +)
20 +from app.integrations.microsoft_patch_tuesday.services.microsoft_patch_tuesday import (
21 + get_available_cycles,
22 +)
23 +from app.integrations.microsoft_patch_tuesday.services.microsoft_patch_tuesday import (
24 + get_patch_tuesday,
25 +)
26 +from app.integrations.microsoft_patch_tuesday.services.microsoft_patch_tuesday import (
27 + get_patch_tuesday_summary,
28 +)
29 +from app.integrations.microsoft_patch_tuesday.services.microsoft_patch_tuesday import (
30 + search_cves_in_patch_tuesday,
31 +)
32 +
33 +microsoft_patch_tuesday_router = APIRouter()
34 +
35 +
36 +@microsoft_patch_tuesday_router.get(
37 + "",
38 + response_model=PatchTuesdayResponse,
39 + description="Get full Patch Tuesday data for a specific cycle",
40 +)
41 +async def get_patch_tuesday_data(
42 + cycle: Optional[str] = Query(
43 + None,
44 + description="Cycle in YYYY-Mmm format (e.g., 2026-Jan). Defaults to current month.",
45 + regex=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
46 + ),
47 + include_epss: bool = Query(True, description="Include EPSS scores in the response"),
48 + include_kev: bool = Query(True, description="Include CISA KEV data in the response"),
49 +) -> PatchTuesdayResponse:
50 + """
51 + Get full Patch Tuesday vulnerability data for a specific cycle.
52 +
53 + This endpoint fetches data from the Microsoft Security Response Center (MSRC) CVRF API,
54 + enriches it with EPSS scores and CISA KEV information, and returns prioritized results.
55 +
56 + **Priority Levels:**
57 + - **P0 (Emergency)**: Known exploited (CISA KEV), very high EPSS (>=0.9), or Critical + high EPSS
58 + - **P1 (High)**: Critical severity, or high CVSS (>=8.0) with elevated EPSS or core enterprise products
59 + - **P2 (Medium)**: Important/Moderate severity, CVSS >=6.0, or EPSS >=0.1
60 + - **P3 (Low)**: All other vulnerabilities
61 +
62 + **Product Families:**
63 + - Windows, Windows Server, Office/M365, Exchange, SharePoint, SQL Server,
64 + Developer Platform, Edge, Azure, Dynamics, Other
65 + """
66 + logger.info(f"Fetching Patch Tuesday data for cycle: {cycle or 'current'}")
67 +
68 + request = PatchTuesdayRequest(
69 + cycle=cycle,
70 + include_epss=include_epss,
71 + include_kev=include_kev,
72 + )
73 +
74 + return await get_patch_tuesday(request)
75 +
76 +
77 +@microsoft_patch_tuesday_router.get(
78 + "/summary",
79 + response_model=PatchTuesdaySummaryResponse,
80 + description="Get Patch Tuesday summary with top prioritized items",
81 +)
82 +async def get_patch_tuesday_summary_endpoint(
83 + cycle: Optional[str] = Query(
84 + None,
85 + description="Cycle in YYYY-Mmm format (e.g., 2026-Jan). Defaults to current month.",
86 + regex=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
87 + ),
88 + include_epss: bool = Query(True, description="Include EPSS scores in the response"),
89 + include_kev: bool = Query(True, description="Include CISA KEV data in the response"),
90 + top_n: int = Query(25, ge=1, le=100, description="Number of top items to include"),
91 +) -> PatchTuesdaySummaryResponse:
92 + """
93 + Get Patch Tuesday summary with top prioritized vulnerabilities.
94 +
95 + This is a lighter endpoint that returns only the summary statistics and
96 + top N prioritized items, suitable for dashboards and quick overviews.
97 + """
98 + logger.info(f"Fetching Patch Tuesday summary for cycle: {cycle or 'current'}")
99 +
100 + request = PatchTuesdayRequest(
101 + cycle=cycle,
102 + include_epss=include_epss,
103 + include_kev=include_kev,
104 + )
105 +
106 + return await get_patch_tuesday_summary(request, top_n=top_n)
107 +
108 +
109 +@microsoft_patch_tuesday_router.get(
110 + "/cycles",
111 + response_model=AvailableCyclesResponse,
112 + description="Get available Patch Tuesday cycles",
113 +)
114 +async def get_cycles() -> AvailableCyclesResponse:
115 + """
116 + Get available Patch Tuesday cycles.
117 +
118 + Returns a list of recent cycles (last 12 months), the current cycle,
119 + and the date of the next Patch Tuesday.
120 + """
121 + logger.info("Fetching available Patch Tuesday cycles")
122 + return await get_available_cycles()
123 +
124 +
125 +@microsoft_patch_tuesday_router.get(
126 + "/search",
127 + response_model=PatchTuesdayResponse,
128 + description="Search for specific CVEs in Patch Tuesday data",
129 +)
130 +async def search_cves(
131 + cve_ids: List[str] = Query(..., description="List of CVE IDs to search for"),
132 + cycle: Optional[str] = Query(
133 + None,
134 + description="Cycle in YYYY-Mmm format to limit search to. Defaults to current month.",
135 + regex=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
136 + ),
137 +) -> PatchTuesdayResponse:
138 + """
139 + Search for specific CVEs in Patch Tuesday data.
140 +
141 + Returns detailed information about the specified CVEs including
142 + EPSS scores, CISA KEV status, and prioritization recommendations.
143 + """
144 + logger.info(f"Searching for CVEs: {cve_ids} in cycle: {cycle or 'current'}")
145 + return await search_cves_in_patch_tuesday(cve_ids, cycle)
146 +
147 +
148 +@microsoft_patch_tuesday_router.get(
149 + "/priority/{priority_level}",
150 + response_model=PatchTuesdayResponse,
151 + description="Get vulnerabilities by priority level",
152 +)
153 +async def get_by_priority(
154 + priority_level: str,
155 + cycle: Optional[str] = Query(
156 + None,
157 + description="Cycle in YYYY-Mmm format. Defaults to current month.",
158 + regex=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
159 + ),
160 + include_epss: bool = Query(True, description="Include EPSS scores"),
161 + include_kev: bool = Query(True, description="Include CISA KEV data"),
162 +) -> PatchTuesdayResponse:
163 + """
164 + Get vulnerabilities filtered by priority level.
165 +
166 + **Valid priority levels:** P0, P1, P2, P3
167 + """
168 + logger.info(f"Fetching {priority_level} priority items for cycle: {cycle or 'current'}")
169 +
170 + priority_level = priority_level.upper()
171 + if priority_level not in ["P0", "P1", "P2", "P3"]:
172 + return PatchTuesdayResponse(
173 + success=False,
174 + message=f"Invalid priority level '{priority_level}'. Valid values: P0, P1, P2, P3",
175 + summary=None,
176 + items=[],
177 + )
178 +
179 + request = PatchTuesdayRequest(
180 + cycle=cycle,
181 + include_epss=include_epss,
182 + include_kev=include_kev,
183 + )
184 +
185 + response = await get_patch_tuesday(request)
186 +
187 + if not response.success:
188 + return response
189 +
190 + # Filter by priority
191 + filtered_items = [item for item in response.items if item.prioritization.priority == priority_level]
192 +
193 + # Update counts
194 + if response.summary:
195 + from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
196 + PatchTuesdaySummary,
197 + )
198 + from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
199 + PriorityCounts,
200 + )
201 +
202 + family_counts = {}
203 + for item in filtered_items:
204 + family_counts[item.affected.family] = family_counts.get(item.affected.family, 0) + 1
205 +
206 + # Create new priority counts with only the selected priority
207 + new_prio_counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0}
208 + new_prio_counts[priority_level] = len(filtered_items)
209 +
210 + summary = PatchTuesdaySummary(
211 + cycle=response.summary.cycle,
212 + patch_tuesday_date=response.summary.patch_tuesday_date,
213 + generated_utc=response.summary.generated_utc,
214 + unique_cves=len(set([x.cve for x in filtered_items])),
215 + total_records=len(filtered_items),
216 + by_priority=PriorityCounts(**new_prio_counts),
217 + by_family=dict(sorted(family_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
218 + by_severity=response.summary.by_severity,
219 + )
220 + else:
221 + summary = None
222 +
223 + return PatchTuesdayResponse(
224 + success=True,
225 + message=f"Found {len(filtered_items)} {priority_level} priority items",
226 + summary=summary,
227 + items=filtered_items,
228 + )
229 +
230 +
231 +@microsoft_patch_tuesday_router.get(
232 + "/kev",
233 + response_model=PatchTuesdayResponse,
234 + description="Get only CISA KEV (Known Exploited Vulnerabilities) items",
235 +)
236 +async def get_kev_items(
237 + cycle: Optional[str] = Query(
238 + None,
239 + description="Cycle in YYYY-Mmm format. Defaults to current month.",
240 + regex=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
241 + ),
242 +) -> PatchTuesdayResponse:
243 + """
244 + Get only vulnerabilities that are in the CISA Known Exploited Vulnerabilities catalog.
245 +
246 + These are actively exploited vulnerabilities that should be prioritized for immediate remediation.
247 + """
248 + logger.info(f"Fetching KEV items for cycle: {cycle or 'current'}")
249 +
250 + request = PatchTuesdayRequest(
251 + cycle=cycle,
252 + include_epss=True,
253 + include_kev=True,
254 + )
255 +
256 + response = await get_patch_tuesday(request)
257 +
258 + if not response.success:
259 + return response
260 +
261 + # Filter to only KEV items
262 + kev_items = [item for item in response.items if item.kev.in_kev]
263 +
264 + return PatchTuesdayResponse(
265 + success=True,
266 + message=f"Found {len(kev_items)} known exploited vulnerabilities",
267 + summary=response.summary,
268 + items=kev_items,
269 + )
backend/app/integrations/microsoft_patch_tuesday/schema/microsoft_patch_tuesday.py new
+155
@@ -0,0 +1,155 @@
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 Field
7 +
8 +
9 +# Request schemas
10 +class PatchTuesdayRequest(BaseModel):
11 + """Request to fetch Patch Tuesday data for a specific cycle"""
12 +
13 + cycle: Optional[str] = Field(
14 + None,
15 + description="CVRF doc id in format YYYY-Mmm (e.g., 2026-Jan). Defaults to current month.",
16 + pattern=r"^\d{4}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
17 + )
18 + include_epss: bool = Field(True, description="Include EPSS scores in the response")
19 + include_kev: bool = Field(True, description="Include CISA KEV data in the response")
20 +
21 +
22 +class PatchTuesdayCVESearchRequest(BaseModel):
23 + """Request to search for specific CVEs in Patch Tuesday data"""
24 +
25 + cve_ids: List[str] = Field(..., description="List of CVE IDs to search for")
26 + cycle: Optional[str] = Field(None, description="Optional cycle to limit search to")
27 +
28 +
29 +# Response component schemas
30 +class CVSSInfo(BaseModel):
31 + """CVSS score information"""
32 +
33 + base: Optional[float] = Field(None, description="CVSS base score")
34 + vector: Optional[str] = Field(None, description="CVSS vector string")
35 +
36 +
37 +class EPSSInfo(BaseModel):
38 + """EPSS score information"""
39 +
40 + score: Optional[float] = Field(None, description="EPSS probability score")
41 + percentile: Optional[float] = Field(None, description="EPSS percentile ranking")
42 + date: Optional[str] = Field(None, description="Date of EPSS score")
43 +
44 +
45 +class KEVInfo(BaseModel):
46 + """CISA Known Exploited Vulnerabilities information"""
47 +
48 + in_kev: bool = Field(False, description="Whether the CVE is in CISA KEV")
49 + date_added: Optional[str] = Field(None, description="Date added to KEV")
50 + due_date: Optional[str] = Field(None, description="Required remediation due date")
51 + required_action: Optional[str] = Field(None, description="Required remediation action")
52 + known_ransomware_campaign_use: Optional[str] = Field(None, description="Known ransomware usage")
53 + vendor_project: Optional[str] = Field(None, description="Vendor or project name")
54 + product: Optional[str] = Field(None, description="Product name")
55 + vulnerability_name: Optional[str] = Field(None, description="Vulnerability name")
56 + short_description: Optional[str] = Field(None, description="Short description")
57 + notes: Optional[str] = Field(None, description="Additional notes")
58 +
59 +
60 +class AffectedProduct(BaseModel):
61 + """Affected product information"""
62 +
63 + product: str = Field(..., description="Product name")
64 + family: str = Field(..., description="Product family classification")
65 + component_hint: Optional[str] = Field(None, description="Component hint from title")
66 +
67 +
68 +class RemediationInfo(BaseModel):
69 + """Remediation information"""
70 +
71 + kbs: List[str] = Field(default_factory=list, description="Related KB article numbers")
72 +
73 +
74 +class PrioritizationInfo(BaseModel):
75 + """Prioritization recommendation"""
76 +
77 + priority: str = Field(..., description="Priority level (P0-P3)")
78 + reason: List[str] = Field(default_factory=list, description="Reasons for priority assignment")
79 + suggested_sla: str = Field(..., description="Suggested SLA for remediation")
80 +
81 +
82 +class SourceInfo(BaseModel):
83 + """Source information for the vulnerability data"""
84 +
85 + msrc_cvrf_id: str = Field(..., description="MSRC CVRF document ID")
86 + msrc_cvrf_url: str = Field(..., description="MSRC CVRF document URL")
87 + cisa_kev_url: str = Field(..., description="CISA KEV feed URL")
88 +
89 +
90 +class PatchTuesdayItem(BaseModel):
91 + """Individual Patch Tuesday vulnerability item"""
92 +
93 + cycle: str = Field(..., description="Patch Tuesday cycle (e.g., 2026-Jan)")
94 + release_type: str = Field("patch_tuesday", description="Release type")
95 + cve: str = Field(..., description="CVE identifier")
96 + title: Optional[str] = Field(None, description="Vulnerability title")
97 + severity: Optional[str] = Field(None, description="Microsoft severity rating")
98 + cvss: CVSSInfo = Field(default_factory=CVSSInfo, description="CVSS information")
99 + epss: EPSSInfo = Field(default_factory=EPSSInfo, description="EPSS information")
100 + kev: KEVInfo = Field(default_factory=KEVInfo, description="KEV information")
101 + affected: AffectedProduct = Field(..., description="Affected product information")
102 + remediation: RemediationInfo = Field(default_factory=RemediationInfo, description="Remediation information")
103 + prioritization: PrioritizationInfo = Field(..., description="Prioritization recommendation")
104 + source: SourceInfo = Field(..., description="Data source information")
105 + timestamp_utc: str = Field(..., description="Timestamp when data was fetched")
106 +
107 +
108 +class PriorityCounts(BaseModel):
109 + """Counts by priority level"""
110 +
111 + P0: int = Field(0, description="Emergency priority count")
112 + P1: int = Field(0, description="High priority count")
113 + P2: int = Field(0, description="Medium priority count")
114 + P3: int = Field(0, description="Low priority count")
115 +
116 +
117 +class PatchTuesdaySummary(BaseModel):
118 + """Summary of Patch Tuesday data"""
119 +
120 + cycle: str = Field(..., description="Patch Tuesday cycle")
121 + patch_tuesday_date: str = Field(..., description="Date of Patch Tuesday")
122 + generated_utc: str = Field(..., description="When the summary was generated")
123 + unique_cves: int = Field(..., description="Number of unique CVEs")
124 + total_records: int = Field(..., description="Total CVE x product records")
125 + by_priority: PriorityCounts = Field(..., description="Counts by priority level")
126 + by_family: Dict[str, int] = Field(default_factory=dict, description="Counts by product family")
127 + by_severity: Dict[str, int] = Field(default_factory=dict, description="Counts by severity")
128 +
129 +
130 +class PatchTuesdayResponse(BaseModel):
131 + """Full Patch Tuesday response"""
132 +
133 + success: bool = Field(..., description="Whether the request was successful")
134 + message: str = Field(..., description="Response message")
135 + summary: Optional[PatchTuesdaySummary] = Field(None, description="Summary of the data")
136 + items: List[PatchTuesdayItem] = Field(default_factory=list, description="Vulnerability items")
137 +
138 +
139 +class PatchTuesdaySummaryResponse(BaseModel):
140 + """Summary-only Patch Tuesday response (without full items)"""
141 +
142 + success: bool = Field(..., description="Whether the request was successful")
143 + message: str = Field(..., description="Response message")
144 + summary: Optional[PatchTuesdaySummary] = Field(None, description="Summary of the data")
145 + top_items: List[PatchTuesdayItem] = Field(default_factory=list, description="Top prioritized items")
146 +
147 +
148 +class AvailableCyclesResponse(BaseModel):
149 + """Response with available Patch Tuesday cycles"""
150 +
151 + success: bool = Field(..., description="Whether the request was successful")
152 + message: str = Field(..., description="Response message")
153 + cycles: List[str] = Field(default_factory=list, description="Available cycles")
154 + current_cycle: str = Field(..., description="Current/default cycle")
155 + next_patch_tuesday: str = Field(..., description="Date of next Patch Tuesday")
backend/app/integrations/microsoft_patch_tuesday/services/microsoft_patch_tuesday.py new
+815
@@ -0,0 +1,815 @@
1 +import datetime as dt
2 +import json
3 +import re
4 +from typing import Any
5 +from typing import Dict
6 +from typing import List
7 +from typing import Optional
8 +from typing import Tuple
9 +
10 +import httpx
11 +from loguru import logger
12 +
13 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
14 + AffectedProduct,
15 +)
16 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
17 + AvailableCyclesResponse,
18 +)
19 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
20 + CVSSInfo,
21 +)
22 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
23 + EPSSInfo,
24 +)
25 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
26 + KEVInfo,
27 +)
28 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
29 + PatchTuesdayItem,
30 +)
31 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
32 + PatchTuesdayRequest,
33 +)
34 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
35 + PatchTuesdayResponse,
36 +)
37 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
38 + PatchTuesdaySummary,
39 +)
40 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
41 + PatchTuesdaySummaryResponse,
42 +)
43 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
44 + PrioritizationInfo,
45 +)
46 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
47 + PriorityCounts,
48 +)
49 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
50 + RemediationInfo,
51 +)
52 +from app.integrations.microsoft_patch_tuesday.schema.microsoft_patch_tuesday import (
53 + SourceInfo,
54 +)
55 +
56 +# Constants
57 +MSRC_CVRF_BASE = "https://api.msrc.microsoft.com/cvrf/v3.0/cvrf"
58 +EPSS_BASE = "https://api.first.org/data/v1/epss"
59 +CISA_KEV_JSON = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
60 +
61 +MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
62 +
63 +
64 +def second_tuesday(year: int, month: int) -> dt.date:
65 + """Return date of the second Tuesday for a given year/month."""
66 + d = dt.date(year, month, 1)
67 + days_to_tuesday = (1 - d.weekday()) % 7
68 + first_tuesday = d + dt.timedelta(days=days_to_tuesday)
69 + return first_tuesday + dt.timedelta(days=7)
70 +
71 +
72 +def get_default_cycle(today: Optional[dt.date] = None) -> str:
73 + """Default cycle is current month in 'YYYY-Mmm' format."""
74 + if today is None:
75 + today = dt.date.today()
76 + return f"{today.year}-{MONTH_ABBR[today.month - 1]}"
77 +
78 +
79 +def parse_cycle(cycle: str) -> Tuple[int, int, str]:
80 + """
81 + Parse cycle in 'YYYY-Mmm' format.
82 + Returns (year, month_int, normalized_cycle).
83 + """
84 + m = re.fullmatch(r"(\d{4})-([A-Za-z]{3})", cycle.strip())
85 + if not m:
86 + raise ValueError("Cycle must be in format YYYY-Mmm (e.g., 2026-Jan)")
87 + year = int(m.group(1))
88 + mon = m.group(2).title()
89 + if mon not in MONTH_ABBR:
90 + raise ValueError(f"Invalid month abbreviation '{mon}'. Use one of: {', '.join(MONTH_ABBR)}")
91 + month = MONTH_ABBR.index(mon) + 1
92 + return year, month, f"{year}-{mon}"
93 +
94 +
95 +def safe_float(x: Any) -> Optional[float]:
96 + """Safely convert a value to float."""
97 + try:
98 + if x is None or x == "":
99 + return None
100 + return float(x)
101 + except Exception:
102 + return None
103 +
104 +
105 +def normalize_text(s: Any) -> Optional[str]:
106 + """Best-effort normalization to a single-line string."""
107 + if s is None:
108 + return None
109 +
110 + if isinstance(s, dict):
111 + for k in ("Value", "value", "Description", "description", "Text", "text", "Title", "title", "Name", "name"):
112 + if k in s and isinstance(s[k], (str, int, float)):
113 + s = s[k]
114 + break
115 + else:
116 + try:
117 + s = json.dumps(s, ensure_ascii=False)
118 + except Exception:
119 + s = str(s)
120 +
121 + if isinstance(s, list):
122 + parts = []
123 + for x in s:
124 + nx = normalize_text(x)
125 + if nx:
126 + parts.append(nx)
127 + s = " | ".join(parts)
128 +
129 + if not isinstance(s, str):
130 + s = str(s)
131 +
132 + s = re.sub(r"\s+", " ", s).strip()
133 + return s or None
134 +
135 +
136 +def product_family(name: Any) -> str:
137 + """Classify product into a coarse family bucket."""
138 + name_s = normalize_text(name) or ""
139 + n = name_s.lower()
140 +
141 + if "windows server" in n:
142 + return "Windows Server"
143 + if "windows" in n:
144 + return "Windows"
145 + if "office" in n or "microsoft 365" in n:
146 + return "Office/M365"
147 + if "exchange" in n:
148 + return "Exchange"
149 + if "sharepoint" in n:
150 + return "SharePoint"
151 + if "sql server" in n:
152 + return "SQL Server"
153 + if "visual studio" in n or ".net" in n:
154 + return "Developer Platform"
155 + if "edge" in n:
156 + return "Edge"
157 + if "azure" in n:
158 + return "Azure"
159 + if "dynamics" in n:
160 + return "Dynamics"
161 + return "Other"
162 +
163 +
164 +def chunk_by_max_chars(items: List[str], max_chars: int = 1900) -> List[List[str]]:
165 + """Chunk items to keep URL parameter size under limits."""
166 + chunks: List[List[str]] = []
167 + current: List[str] = []
168 + current_len = 0
169 + for it in items:
170 + add_len = len(it) + (1 if current else 0)
171 + if current and current_len + add_len > max_chars:
172 + chunks.append(current)
173 + current = [it]
174 + current_len = len(it)
175 + else:
176 + current.append(it)
177 + current_len += add_len
178 + if current:
179 + chunks.append(current)
180 + return chunks
181 +
182 +
183 +async def fetch_json(url: str, params: Optional[Dict[str, str]] = None, timeout: int = 60) -> Dict[str, Any]:
184 + """Fetch JSON from a URL with proper error handling."""
185 + headers = {
186 + "Accept": "application/json",
187 + "User-Agent": "CoPilot-PatchTuesday/1.0",
188 + }
189 +
190 + async with httpx.AsyncClient(timeout=timeout) as client:
191 + response = await client.get(url, headers=headers, params=params)
192 +
193 + if response.status_code != 200:
194 + raise RuntimeError(f"GET {url} failed: HTTP {response.status_code} - {response.text[:300]}")
195 +
196 + ctype = (response.headers.get("content-type") or "").lower()
197 + body = response.text.lstrip()
198 + if "application/json" not in ctype and body.startswith("<"):
199 + raise RuntimeError(
200 + f"Endpoint returned non-JSON (Content-Type: {ctype}). " "The MSRC API may have returned XML instead of JSON.",
201 + )
202 +
203 + try:
204 + return response.json()
205 + except Exception as e:
206 + raise RuntimeError(f"Response was not valid JSON: {e}. First 300 chars: {response.text[:300]}") from e
207 +
208 +
209 +async def fetch_epss_scores(cves: List[str]) -> Dict[str, Dict[str, Any]]:
210 + """Fetch EPSS scores for a list of CVEs."""
211 + out: Dict[str, Dict[str, Any]] = {}
212 + cves = sorted(set([c.strip().upper() for c in cves if c and str(c).strip()]))
213 +
214 + if not cves:
215 + return out
216 +
217 + logger.info(f"Fetching EPSS scores for {len(cves)} CVEs")
218 +
219 + for chunk in chunk_by_max_chars(cves):
220 + try:
221 + params = {"cve": ",".join(chunk)}
222 + data = await fetch_json(EPSS_BASE, params=params)
223 +
224 + for row in data.get("data", []) or []:
225 + cve = (row.get("cve") or "").upper()
226 + if not cve:
227 + continue
228 + out[cve] = {
229 + "epss": safe_float(row.get("epss")),
230 + "percentile": safe_float(row.get("percentile")),
231 + "date": row.get("date"),
232 + }
233 + except Exception as e:
234 + logger.warning(f"Error fetching EPSS chunk: {e}")
235 + continue
236 +
237 + logger.info(f"Retrieved EPSS scores for {len(out)} CVEs")
238 + return out
239 +
240 +
241 +async def fetch_kev_data() -> Dict[str, Dict[str, Any]]:
242 + """Fetch and parse CISA KEV data."""
243 + logger.info("Fetching CISA KEV data")
244 +
245 + try:
246 + kev_doc = await fetch_json(CISA_KEV_JSON)
247 + except Exception as e:
248 + logger.error(f"Failed to fetch KEV data: {e}")
249 + return {}
250 +
251 + out: Dict[str, Dict[str, Any]] = {}
252 + vulns = kev_doc.get("vulnerabilities") or []
253 + if isinstance(vulns, dict):
254 + vulns = [vulns]
255 +
256 + for v in vulns:
257 + if not isinstance(v, dict):
258 + continue
259 + cve = (normalize_text(v.get("cveID") or v.get("cveId") or v.get("CVE")) or "").upper()
260 + if not cve.startswith("CVE-"):
261 + continue
262 +
263 + out[cve] = {
264 + "in_kev": True,
265 + "date_added": normalize_text(v.get("dateAdded")),
266 + "due_date": normalize_text(v.get("dueDate")),
267 + "required_action": normalize_text(v.get("requiredAction")),
268 + "known_ransomware_campaign_use": normalize_text(v.get("knownRansomwareCampaignUse")),
269 + "vendor_project": normalize_text(v.get("vendorProject")),
270 + "product": normalize_text(v.get("product")),
271 + "vulnerability_name": normalize_text(v.get("vulnerabilityName")),
272 + "short_description": normalize_text(v.get("shortDescription")),
273 + "notes": normalize_text(v.get("notes")),
274 + }
275 +
276 + logger.info(f"Loaded {len(out)} CVEs from CISA KEV")
277 + return out
278 +
279 +
280 +def extract_cvss(vuln: Dict[str, Any]) -> Tuple[Optional[float], Optional[str]]:
281 + """Extract CVSS score and vector from vulnerability data."""
282 + sets = vuln.get("CVSSScoreSets") or vuln.get("CvssScoreSets") or []
283 + if isinstance(sets, dict):
284 + sets = [sets]
285 +
286 + best_score = None
287 + best_vector = None
288 +
289 + for s in sets:
290 + if not isinstance(s, dict):
291 + continue
292 + score = safe_float(s.get("BaseScore") or s.get("baseScore") or s.get("Score"))
293 + vector = normalize_text(s.get("Vector") or s.get("vectorString") or s.get("VectorString"))
294 + if score is None:
295 + continue
296 + if best_score is None or score > best_score:
297 + best_score = score
298 + best_vector = vector
299 +
300 + return best_score, best_vector
301 +
302 +
303 +def extract_severity(vuln: Dict[str, Any]) -> Optional[str]:
304 + """Extract severity from vulnerability data."""
305 + threats = vuln.get("Threats") or []
306 + if isinstance(threats, dict):
307 + threats = [threats]
308 + for t in threats:
309 + if not isinstance(t, dict):
310 + continue
311 + ttype = normalize_text(t.get("Type") or t.get("type") or "")
312 + desc = normalize_text(t.get("Description") or t.get("description"))
313 + if ttype and "severity" in ttype.lower() and desc:
314 + return desc.title()
315 +
316 + sev = normalize_text(vuln.get("Severity") or vuln.get("severity"))
317 + return sev.title() if sev else None
318 +
319 +
320 +def extract_kbs(vuln: Dict[str, Any]) -> List[str]:
321 + """Extract KB article numbers from remediations."""
322 + kbs = set()
323 + rems = vuln.get("Remediations") or []
324 + if isinstance(rems, dict):
325 + rems = [rems]
326 + for r in rems:
327 + if not isinstance(r, dict):
328 + continue
329 + for field in ["Description", "URL", "Url", "description", "url"]:
330 + val = r.get(field)
331 + if not val:
332 + continue
333 + for kb in re.findall(r"\bKB\d{6,8}\b", str(val), flags=re.IGNORECASE):
334 + kbs.add(kb.upper())
335 + return sorted(kbs)
336 +
337 +
338 +def build_product_lookup(doc: Dict[str, Any]) -> Dict[str, str]:
339 + """Build ProductID to product name lookup."""
340 + lookup: Dict[str, str] = {}
341 +
342 + pt = doc.get("ProductTree") or {}
343 + fp = pt.get("FullProductName") or []
344 + if isinstance(fp, dict):
345 + fp = [fp]
346 +
347 + for item in fp:
348 + if not isinstance(item, dict):
349 + continue
350 + pid = item.get("ProductID") or item.get("productId") or item.get("ProductId")
351 + val = normalize_text(item.get("Value") or item.get("value") or item.get("Name") or item.get("name"))
352 + if pid and val:
353 + lookup[str(pid)] = val
354 +
355 + return lookup
356 +
357 +
358 +def extract_affected_products(vuln: Dict[str, Any], product_lookup: Dict[str, str]) -> List[str]:
359 + """Extract affected products from vulnerability data."""
360 + names = set()
361 +
362 + statuses = vuln.get("ProductStatuses") or vuln.get("ProductStatus") or []
363 + if isinstance(statuses, dict):
364 + statuses = [statuses]
365 +
366 + for st in statuses:
367 + if not isinstance(st, dict):
368 + continue
369 + for _, v in st.items():
370 + if isinstance(v, list):
371 + for pid in v:
372 + pname = product_lookup.get(str(pid))
373 + if pname:
374 + names.add(pname)
375 + elif isinstance(v, (str, int)):
376 + pname = product_lookup.get(str(v))
377 + if pname:
378 + names.add(pname)
379 +
380 + prods = vuln.get("Products")
381 + if isinstance(prods, list):
382 + for pid in prods:
383 + pname = product_lookup.get(str(pid))
384 + if pname:
385 + names.add(pname)
386 +
387 + return sorted(names)
388 +
389 +
390 +def calculate_priority(
391 + severity: Optional[str],
392 + cvss: Optional[float],
393 + epss: Optional[float],
394 + family: str,
395 + in_kev: bool,
396 +) -> Tuple[str, List[str], str]:
397 + """
398 + Calculate priority level (P0-P3) based on various factors.
399 +
400 + P0: CISA KEV, OR EPSS >= 0.9, OR (Critical and EPSS >= 0.7)
401 + P1: Critical OR (CVSS >= 8.0 and (EPSS >= 0.3 or core enterprise families))
402 + P2: Important/Moderate OR CVSS >= 6.0 OR EPSS >= 0.1
403 + P3: Otherwise
404 + """
405 + if in_kev:
406 + reasons = ["CISA KEV (known exploited)"]
407 + if epss is not None:
408 + reasons.append(f"EPSS={epss:.3f}")
409 + if cvss is not None:
410 + reasons.append(f"CVSS={cvss:.1f}")
411 + sla = "Emergency: known exploited. Patch/mitigate immediately; prioritize exposed and Tier-0 assets first."
412 + return "P0", reasons, sla
413 +
414 + reasons: List[str] = []
415 + sev = (severity or "").lower()
416 + e = epss if epss is not None else 0.0
417 + s = cvss if cvss is not None else 0.0
418 +
419 + core_families = {"Windows", "Windows Server", "Office/M365", "Exchange", "Edge"}
420 +
421 + if e >= 0.9 or (sev == "critical" and e >= 0.7):
422 + reasons += ["Very high EPSS" if e >= 0.9 else "Critical severity + high EPSS"]
423 + sla = "Emergency: validate mitigations immediately; deploy to highest-risk assets within 24h."
424 + return "P0", reasons, sla
425 +
426 + if sev == "critical":
427 + reasons.append("Critical severity")
428 + if s >= 8.0:
429 + reasons.append("High CVSS (>= 8.0)")
430 + if e >= 0.3:
431 + reasons.append("Elevated EPSS (>= 0.3)")
432 + if family in core_families:
433 + reasons.append(f"High enterprise footprint ({family})")
434 +
435 + if (sev == "critical") or (s >= 8.0 and (e >= 0.3 or family in core_families)):
436 + sla = "High: deploy to pilot in 24–48h; broad rollout in 72h."
437 + return "P1", reasons, sla
438 +
439 + if sev in {"important", "moderate"}:
440 + reasons.append(f"{severity.title() if severity else 'Unknown'} severity")
441 + if s >= 6.0:
442 + reasons.append("CVSS (>= 6.0)")
443 + if e >= 0.1:
444 + reasons.append("EPSS (>= 0.1)")
445 +
446 + if sev in {"important", "moderate"} or s >= 6.0 or e >= 0.1:
447 + sla = "Medium: deploy in the next standard patch window (7–14 days), sooner for exposed assets."
448 + return "P2", reasons, sla
449 +
450 + sla = "Low: patch in routine maintenance (30 days) unless asset criticality dictates otherwise."
451 + return "P3", reasons, sla
452 +
453 +
454 +async def fetch_patch_tuesday_data(
455 + cycle: Optional[str] = None,
456 + include_epss: bool = True,
457 + include_kev: bool = True,
458 +) -> Tuple[List[PatchTuesdayItem], PatchTuesdaySummary]:
459 + """
460 + Fetch and parse Patch Tuesday data for a given cycle.
461 +
462 + Args:
463 + cycle: Cycle in YYYY-Mmm format (e.g., 2026-Jan). Defaults to current month.
464 + include_epss: Whether to fetch and include EPSS scores.
465 + include_kev: Whether to fetch and include CISA KEV data.
466 +
467 + Returns:
468 + Tuple of (items, summary)
469 + """
470 + # Parse cycle
471 + if cycle:
472 + year, month, cycle_norm = parse_cycle(cycle)
473 + else:
474 + cycle_norm = get_default_cycle()
475 + year, month, cycle_norm = parse_cycle(cycle_norm)
476 +
477 + pt_date = second_tuesday(year, month)
478 + url = f"{MSRC_CVRF_BASE}/{cycle_norm}"
479 +
480 + logger.info(f"Fetching Patch Tuesday data for cycle {cycle_norm} (date: {pt_date.isoformat()})")
481 +
482 + # Fetch CVRF document
483 + doc = await fetch_json(url)
484 +
485 + # Build product lookup
486 + product_lookup = build_product_lookup(doc)
487 +
488 + # Parse vulnerabilities
489 + vulnerabilities = doc.get("Vulnerability") or doc.get("Vulnerabilities") or []
490 + if isinstance(vulnerabilities, dict):
491 + vulnerabilities = [vulnerabilities]
492 +
493 + # Collect all CVEs for enrichment
494 + cves_all: List[str] = []
495 + for v in vulnerabilities:
496 + if not isinstance(v, dict):
497 + continue
498 + cve = (normalize_text(v.get("CVE") or v.get("Cve") or v.get("cve")) or "").upper()
499 + if cve.startswith("CVE-"):
500 + cves_all.append(cve)
501 +
502 + # Fetch enrichment data
503 + epss_map: Dict[str, Dict[str, Any]] = {}
504 + kev_map: Dict[str, Dict[str, Any]] = {}
505 +
506 + if include_epss:
507 + epss_map = await fetch_epss_scores(cves_all)
508 +
509 + if include_kev:
510 + kev_map = await fetch_kev_data()
511 +
512 + now_utc = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
513 +
514 + items: List[PatchTuesdayItem] = []
515 + prio_counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0}
516 + family_counts: Dict[str, int] = {}
517 + severity_counts: Dict[str, int] = {}
518 +
519 + for v in vulnerabilities:
520 + if not isinstance(v, dict):
521 + continue
522 +
523 + cve = (normalize_text(v.get("CVE") or v.get("Cve") or v.get("cve")) or "").upper()
524 + if not cve.startswith("CVE-"):
525 + continue
526 +
527 + title = normalize_text(v.get("Title") or v.get("title"))
528 + severity = extract_severity(v)
529 + cvss_score, cvss_vector = extract_cvss(v)
530 + kbs = extract_kbs(v)
531 +
532 + affected_products = extract_affected_products(v, product_lookup)
533 + if not affected_products:
534 + affected_products = ["(Unknown product mapping)"]
535 +
536 + # Get enrichment data
537 + epss = epss_map.get(cve, {})
538 + epss_score = epss.get("epss")
539 + epss_pct = epss.get("percentile")
540 + epss_date = epss.get("date")
541 +
542 + kev = kev_map.get(cve, {"in_kev": False})
543 + in_kev = bool(kev.get("in_kev"))
544 +
545 + # Track severity counts
546 + if severity:
547 + severity_counts[severity] = severity_counts.get(severity, 0) + 1
548 +
549 + for prod in affected_products:
550 + prod_norm = normalize_text(prod) or prod
551 + fam = product_family(prod_norm)
552 + family_counts[fam] = family_counts.get(fam, 0) + 1
553 +
554 + prio, reasons, sla = calculate_priority(severity, cvss_score, epss_score, fam, in_kev)
555 + prio_counts[prio] = prio_counts.get(prio, 0) + 1
556 +
557 + item = PatchTuesdayItem(
558 + cycle=cycle_norm,
559 + release_type="patch_tuesday",
560 + cve=cve,
561 + title=title,
562 + severity=severity,
563 + cvss=CVSSInfo(base=cvss_score, vector=cvss_vector),
564 + epss=EPSSInfo(score=epss_score, percentile=epss_pct, date=epss_date),
565 + kev=KEVInfo(
566 + in_kev=in_kev,
567 + date_added=kev.get("date_added"),
568 + due_date=kev.get("due_date"),
569 + required_action=kev.get("required_action"),
570 + known_ransomware_campaign_use=kev.get("known_ransomware_campaign_use"),
571 + vendor_project=kev.get("vendor_project"),
572 + product=kev.get("product"),
573 + vulnerability_name=kev.get("vulnerability_name"),
574 + short_description=kev.get("short_description"),
575 + notes=kev.get("notes"),
576 + ),
577 + affected=AffectedProduct(
578 + product=prod_norm,
579 + family=fam,
580 + component_hint=title,
581 + ),
582 + remediation=RemediationInfo(kbs=kbs),
583 + prioritization=PrioritizationInfo(
584 + priority=prio,
585 + reason=reasons,
586 + suggested_sla=sla,
587 + ),
588 + source=SourceInfo(
589 + msrc_cvrf_id=cycle_norm,
590 + msrc_cvrf_url=url,
591 + cisa_kev_url=CISA_KEV_JSON,
592 + ),
593 + timestamp_utc=now_utc,
594 + )
595 + items.append(item)
596 +
597 + # Sort by priority, then EPSS desc, then CVSS desc
598 + prio_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
599 + items.sort(
600 + key=lambda r: (
601 + prio_rank.get(r.prioritization.priority, 9),
602 + -(r.epss.score or 0.0),
603 + -(r.cvss.base or 0.0),
604 + r.cve,
605 + r.affected.product,
606 + ),
607 + )
608 +
609 + summary = PatchTuesdaySummary(
610 + cycle=cycle_norm,
611 + patch_tuesday_date=pt_date.isoformat(),
612 + generated_utc=now_utc,
613 + unique_cves=len(set([x.cve for x in items])),
614 + total_records=len(items),
615 + by_priority=PriorityCounts(**prio_counts),
616 + by_family=dict(sorted(family_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
617 + by_severity=dict(sorted(severity_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
618 + )
619 +
620 + logger.info(f"Processed {summary.unique_cves} unique CVEs, {summary.total_records} total records for cycle {cycle_norm}")
621 +
622 + return items, summary
623 +
624 +
625 +async def get_patch_tuesday(request: PatchTuesdayRequest) -> PatchTuesdayResponse:
626 + """
627 + Get full Patch Tuesday data for a cycle.
628 +
629 + Args:
630 + request: Request containing cycle and enrichment options.
631 +
632 + Returns:
633 + PatchTuesdayResponse with all items and summary.
634 + """
635 + try:
636 + items, summary = await fetch_patch_tuesday_data(
637 + cycle=request.cycle,
638 + include_epss=request.include_epss,
639 + include_kev=request.include_kev,
640 + )
641 +
642 + return PatchTuesdayResponse(
643 + success=True,
644 + message=f"Successfully retrieved {summary.unique_cves} CVEs for cycle {summary.cycle}",
645 + summary=summary,
646 + items=items,
647 + )
648 + except ValueError as e:
649 + logger.error(f"Invalid request: {e}")
650 + return PatchTuesdayResponse(
651 + success=False,
652 + message=str(e),
653 + summary=None,
654 + items=[],
655 + )
656 + except Exception as e:
657 + logger.error(f"Error fetching Patch Tuesday data: {e}")
658 + return PatchTuesdayResponse(
659 + success=False,
660 + message=f"Failed to fetch Patch Tuesday data: {e}",
661 + summary=None,
662 + items=[],
663 + )
664 +
665 +
666 +async def get_patch_tuesday_summary(request: PatchTuesdayRequest, top_n: int = 25) -> PatchTuesdaySummaryResponse:
667 + """
668 + Get Patch Tuesday summary with top prioritized items.
669 +
670 + Args:
671 + request: Request containing cycle and enrichment options.
672 + top_n: Number of top items to include (default 25).
673 +
674 + Returns:
675 + PatchTuesdaySummaryResponse with summary and top items.
676 + """
677 + try:
678 + items, summary = await fetch_patch_tuesday_data(
679 + cycle=request.cycle,
680 + include_epss=request.include_epss,
681 + include_kev=request.include_kev,
682 + )
683 +
684 + return PatchTuesdaySummaryResponse(
685 + success=True,
686 + message=f"Successfully retrieved summary for cycle {summary.cycle}",
687 + summary=summary,
688 + top_items=items[:top_n],
689 + )
690 + except ValueError as e:
691 + logger.error(f"Invalid request: {e}")
692 + return PatchTuesdaySummaryResponse(
693 + success=False,
694 + message=str(e),
695 + summary=None,
696 + top_items=[],
697 + )
698 + except Exception as e:
699 + logger.error(f"Error fetching Patch Tuesday summary: {e}")
700 + return PatchTuesdaySummaryResponse(
701 + success=False,
702 + message=f"Failed to fetch Patch Tuesday summary: {e}",
703 + summary=None,
704 + top_items=[],
705 + )
706 +
707 +
708 +async def get_available_cycles() -> AvailableCyclesResponse:
709 + """
710 + Get available Patch Tuesday cycles.
711 +
712 + Returns recent cycles and information about the current/next Patch Tuesday.
713 + """
714 + try:
715 + today = dt.date.today()
716 + current_cycle = get_default_cycle(today)
717 +
718 + # Generate list of recent cycles (last 12 months)
719 + cycles = []
720 + for i in range(12):
721 + d = today - dt.timedelta(days=i * 30)
722 + cycle = get_default_cycle(d)
723 + if cycle not in cycles:
724 + cycles.append(cycle)
725 +
726 + # Calculate next Patch Tuesday
727 + year, month, _ = parse_cycle(current_cycle)
728 + pt_date = second_tuesday(year, month)
729 +
730 + if today > pt_date:
731 + # Move to next month
732 + if month == 12:
733 + next_year, next_month = year + 1, 1
734 + else:
735 + next_year, next_month = year, month + 1
736 + next_pt_date = second_tuesday(next_year, next_month)
737 + else:
738 + next_pt_date = pt_date
739 +
740 + return AvailableCyclesResponse(
741 + success=True,
742 + message="Successfully retrieved available cycles",
743 + cycles=cycles,
744 + current_cycle=current_cycle,
745 + next_patch_tuesday=next_pt_date.isoformat(),
746 + )
747 + except Exception as e:
748 + logger.error(f"Error getting available cycles: {e}")
749 + return AvailableCyclesResponse(
750 + success=False,
751 + message=f"Failed to get available cycles: {e}",
752 + cycles=[],
753 + current_cycle=get_default_cycle(),
754 + next_patch_tuesday="",
755 + )
756 +
757 +
758 +async def search_cves_in_patch_tuesday(cve_ids: List[str], cycle: Optional[str] = None) -> PatchTuesdayResponse:
759 + """
760 + Search for specific CVEs in Patch Tuesday data.
761 +
762 + Args:
763 + cve_ids: List of CVE IDs to search for.
764 + cycle: Optional cycle to limit search to.
765 +
766 + Returns:
767 + PatchTuesdayResponse with matching items.
768 + """
769 + try:
770 + request = PatchTuesdayRequest(cycle=cycle)
771 + response = await get_patch_tuesday(request)
772 +
773 + if not response.success:
774 + return response
775 +
776 + # Filter items to only matching CVEs
777 + cve_set = set([c.upper() for c in cve_ids])
778 + matching_items = [item for item in response.items if item.cve.upper() in cve_set]
779 +
780 + # Recalculate summary for matched items
781 + if matching_items and response.summary:
782 + prio_counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0}
783 + family_counts: Dict[str, int] = {}
784 +
785 + for item in matching_items:
786 + prio_counts[item.prioritization.priority] = prio_counts.get(item.prioritization.priority, 0) + 1
787 + family_counts[item.affected.family] = family_counts.get(item.affected.family, 0) + 1
788 +
789 + summary = PatchTuesdaySummary(
790 + cycle=response.summary.cycle,
791 + patch_tuesday_date=response.summary.patch_tuesday_date,
792 + generated_utc=response.summary.generated_utc,
793 + unique_cves=len(set([x.cve for x in matching_items])),
794 + total_records=len(matching_items),
795 + by_priority=PriorityCounts(**prio_counts),
796 + by_family=dict(sorted(family_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
797 + by_severity=response.summary.by_severity,
798 + )
799 + else:
800 + summary = None
801 +
802 + return PatchTuesdayResponse(
803 + success=True,
804 + message=f"Found {len(matching_items)} items matching {len(cve_ids)} CVE(s)",
805 + summary=summary,
806 + items=matching_items,
807 + )
808 + except Exception as e:
809 + logger.error(f"Error searching CVEs: {e}")
810 + return PatchTuesdayResponse(
811 + success=False,
812 + message=f"Failed to search CVEs: {e}",
813 + summary=None,
814 + items=[],
815 + )
backend/app/routers/microsoft_patch_tuesday.py new
+15
@@ -0,0 +1,15 @@
1 +from fastapi import APIRouter
2 +
3 +from app.integrations.microsoft_patch_tuesday.routes.microsoft_patch_tuesday import (
4 + microsoft_patch_tuesday_router,
5 +)
6 +
7 +# Instantiate the APIRouter
8 +router = APIRouter()
9 +
10 +# Include the Microsoft Patch Tuesday related routes
11 +router.include_router(
12 + microsoft_patch_tuesday_router,
13 + prefix="/patch-tuesday",
14 + tags=["Microsoft Patch Tuesday"],
15 +)
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.39"
10 +CURRENT_VERSION = "0.1.40"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
backend/copilot.py
+2
@@ -60,6 +60,7 @@ from app.routers import influxdb
60 from app.routers import integrations
61 from app.routers import license
62 from app.routers import logs
63 +from app.routers import microsoft_patch_tuesday
64 from app.routers import mimecast
65 from app.routers import modules
66 from app.routers import monitoring_alert
@@ -135,6 +136,7 @@ api_router.include_router(cortex.router)
136 api_router.include_router(velociraptor.router)
137 api_router.include_router(shuffle.router)
138 api_router.include_router(sublime.router)
139 +api_router.include_router(microsoft_patch_tuesday.router)
140 api_router.include_router(customers.router)
141 api_router.include_router(healthcheck.router)
142 api_router.include_router(smtp.router)
frontend/src/api/endpoints/patchTuesday.ts new
+91
@@ -0,0 +1,91 @@
1 +import type {
2 + AvailableCyclesResponse,
3 + PatchTuesdayPriorityQuery,
4 + PatchTuesdayQuery,
5 + PatchTuesdayResponse,
6 + PatchTuesdaySearchQuery,
7 + PatchTuesdaySummaryQuery,
8 + PatchTuesdaySummaryResponse
9 +} from "@/types/patchTuesday.d"
10 +import { HttpClient } from "../httpClient"
11 +
12 +const BASE_PATH = "/patch-tuesday"
13 +
14 +export default {
15 + /**
16 + * Get full Patch Tuesday data for a specific cycle
17 + * Includes all CVE x product records with EPSS and KEV enrichment
18 + */
19 + getPatchTuesday(query?: PatchTuesdayQuery, signal?: AbortSignal) {
20 + return HttpClient.get<PatchTuesdayResponse>(BASE_PATH, {
21 + params: {
22 + cycle: query?.cycle,
23 + include_epss: query?.include_epss !== false,
24 + include_kev: query?.include_kev !== false
25 + },
26 + signal
27 + })
28 + },
29 +
30 + /**
31 + * Get Patch Tuesday summary with top prioritized items
32 + * Lighter endpoint suitable for dashboards
33 + */
34 + getSummary(query?: PatchTuesdaySummaryQuery, signal?: AbortSignal) {
35 + return HttpClient.get<PatchTuesdaySummaryResponse>(`${BASE_PATH}/summary`, {
36 + params: {
37 + cycle: query?.cycle,
38 + include_epss: query?.include_epss !== false,
39 + include_kev: query?.include_kev !== false,
40 + top_n: query?.top_n || 25
41 + },
42 + signal
43 + })
44 + },
45 +
46 + /**
47 + * Get available Patch Tuesday cycles
48 + * Returns list of recent cycles and next Patch Tuesday date
49 + */
50 + getCycles(signal?: AbortSignal) {
51 + return HttpClient.get<AvailableCyclesResponse>(`${BASE_PATH}/cycles`, { signal })
52 + },
53 +
54 + /**
55 + * Search for specific CVEs in Patch Tuesday data
56 + */
57 + searchCVEs(query: PatchTuesdaySearchQuery, signal?: AbortSignal) {
58 + return HttpClient.get<PatchTuesdayResponse>(`${BASE_PATH}/search`, {
59 + params: {
60 + cve_ids: query.cve_ids,
61 + cycle: query.cycle
62 + },
63 + signal
64 + })
65 + },
66 +
67 + /**
68 + * Get vulnerabilities filtered by priority level (P0, P1, P2, P3)
69 + */
70 + getByPriority(query: PatchTuesdayPriorityQuery, signal?: AbortSignal) {
71 + return HttpClient.get<PatchTuesdayResponse>(`${BASE_PATH}/priority/${query.priority_level}`, {
72 + params: {
73 + cycle: query.cycle,
74 + include_epss: query.include_epss !== false,
75 + include_kev: query.include_kev !== false
76 + },
77 + signal
78 + })
79 + },
80 +
81 + /**
82 + * Get only CISA KEV (Known Exploited Vulnerabilities) items
83 + * These are actively exploited and should be prioritized immediately
84 + */
85 + getKEVItems(cycle?: string, signal?: AbortSignal) {
86 + return HttpClient.get<PatchTuesdayResponse>(`${BASE_PATH}/kev`, {
87 + params: { cycle },
88 + signal
89 + })
90 + }
91 +}
frontend/src/api/index.ts
+2
@@ -19,6 +19,7 @@ import license from "./endpoints/license"
19 import logs from "./endpoints/logs"
20 import monitoringAlerts from "./endpoints/monitoringAlerts"
21 import networkConnectors from "./endpoints/networkConnectors"
22 +import patchTuesday from "./endpoints/patchTuesday"
23 import portainer from "./endpoints/portainer"
24 import reporting from "./endpoints/reporting"
25 import sca from "./endpoints/sca"
@@ -71,6 +72,7 @@ export default {
72 vulnerabilities,
73 sca,
74 wazuh,
75 + patchTuesday,
76 portainer,
77 shuffle,
78 copilotMCP,
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -253,6 +253,19 @@ export default function getItems(): MenuMixedOption[] {
253 { default: () => "Vulnerability Overview" }
254 ),
255 key: "VulnerabilityOverview"
256 + },
257 + {
258 + label: () =>
259 + h(
260 + RouterLink,
261 + {
262 + to: {
263 + name: "PatchTuesday"
264 + }
265 + },
266 + { default: () => "Patch Tuesday" }
267 + ),
268 + key: "PatchTuesday"
269 },
270 {
271 label: () =>
frontend/src/components/patchTuesday/PatchTuesdayCard.vue new
+244
@@ -0,0 +1,244 @@
1 +<template>
2 + <n-card
3 + size="small"
4 + :bordered="false"
5 + hoverable
6 + class="patch-tuesday-card"
7 + :class="[`priority-${item.prioritization.priority.toLowerCase()}`]"
8 + >
9 + <!-- Header -->
10 + <div class="card-header">
11 + <div class="cve-info">
12 + <span class="cve-id">{{ item.cve }}</span>
13 + <PatchTuesdayPriorityBadge :priority="item.prioritization.priority" />
14 + </div>
15 + <div class="badges">
16 + <n-tag v-if="item.kev.in_kev" type="error" size="small" round>
17 + <template #icon>
18 + <Icon :name="AlertIcon" />
19 + </template>
20 + KEV
21 + </n-tag>
22 + <n-tag v-if="item.severity" :type="getSeverityType(item.severity)" size="small">
23 + {{ item.severity }}
24 + </n-tag>
25 + </div>
26 + </div>
27 +
28 + <!-- Title -->
29 + <p class="card-title">{{ item.title || "No title available" }}</p>
30 +
31 + <!-- Product Info -->
32 + <div class="product-info">
33 + <n-tag size="small" :bordered="false">
34 + {{ item.affected.family }}
35 + </n-tag>
36 + <span class="product-name">{{ truncateProduct(item.affected.product) }}</span>
37 + </div>
38 +
39 + <!-- Scores Row -->
40 + <div class="scores-row">
41 + <div v-if="item.cvss.base !== null" class="score-item">
42 + <span class="score-label">CVSS</span>
43 + <span class="score-value" :class="getCvssClass(item.cvss.base)">
44 + {{ item.cvss.base.toFixed(1) }}
45 + </span>
46 + </div>
47 + <div v-if="item.epss.score !== null" class="score-item">
48 + <span class="score-label">EPSS</span>
49 + <span class="score-value">{{ (item.epss.score * 100).toFixed(1) }}%</span>
50 + </div>
51 + <div v-if="item.epss.percentile !== null" class="score-item">
52 + <span class="score-label">Percentile</span>
53 + <span class="score-value">{{ (item.epss.percentile * 100).toFixed(0) }}%</span>
54 + </div>
55 + </div>
56 +
57 + <!-- KB Articles -->
58 + <div v-if="item.remediation.kbs.length > 0" class="kb-row">
59 + <Icon :name="LinkIcon" :size="14" />
60 + <span class="kb-list">{{ item.remediation.kbs.slice(0, 3).join(", ") }}</span>
61 + <span v-if="item.remediation.kbs.length > 3" class="kb-more">
62 + +{{ item.remediation.kbs.length - 3 }} more
63 + </span>
64 + </div>
65 +
66 + <!-- SLA Hint -->
67 + <div class="sla-hint">
68 + <Icon :name="ClockIcon" :size="14" />
69 + <span>{{ getSlaHint(item.prioritization.suggested_sla) }}</span>
70 + </div>
71 + </n-card>
72 +</template>
73 +
74 +<script setup lang="ts">
75 +import type { PatchTuesdayItem } from "@/types/patchTuesday.d"
76 +import { NCard, NTag } from "naive-ui"
77 +import Icon from "@/components/common/Icon.vue"
78 +import PatchTuesdayPriorityBadge from "./PatchTuesdayPriorityBadge.vue"
79 +
80 +defineProps<{
81 + item: PatchTuesdayItem
82 +}>()
83 +const AlertIcon = "carbon:warning"
84 +const ClockIcon = "carbon:time"
85 +const LinkIcon = "carbon:link"
86 +
87 +function getSeverityType(severity: string): "error" | "warning" | "info" | "default" {
88 + const s = severity.toLowerCase()
89 + if (s === "critical") return "error"
90 + if (s === "important") return "warning"
91 + if (s === "moderate") return "info"
92 + return "default"
93 +}
94 +
95 +function getCvssClass(score: number): string {
96 + if (score >= 9.0) return "critical"
97 + if (score >= 7.0) return "high"
98 + if (score >= 4.0) return "medium"
99 + return "low"
100 +}
101 +
102 +function truncateProduct(product: string): string {
103 + if (product.length <= 40) return product
104 + return `${product.substring(0, 40)}...`
105 +}
106 +
107 +function getSlaHint(sla: string): string {
108 + if (sla.includes("immediately") || sla.includes("24h")) return "Patch immediately"
109 + if (sla.includes("72h")) return "Patch within 72 hours"
110 + if (sla.includes("7-14")) return "Patch within 7-14 days"
111 + return "Patch within 30 days"
112 +}
113 +</script>
114 +
115 +<style scoped lang="scss">
116 +.patch-tuesday-card {
117 + border-radius: 8px;
118 + cursor: pointer;
119 + transition: all 0.2s ease;
120 + border-left: 4px solid transparent;
121 + background: var(--bg-secondary-color);
122 +
123 + &.priority-p0 {
124 + border-left-color: #ef4444;
125 + }
126 +
127 + &.priority-p1 {
128 + border-left-color: #f97316;
129 + }
130 +
131 + &.priority-p2 {
132 + border-left-color: #eab308;
133 + }
134 +
135 + &.priority-p3 {
136 + border-left-color: #22c55e;
137 + }
138 +
139 + .card-header {
140 + display: flex;
141 + justify-content: space-between;
142 + align-items: flex-start;
143 + margin-bottom: 8px;
144 +
145 + .cve-info {
146 + display: flex;
147 + align-items: center;
148 + gap: 8px;
149 +
150 + .cve-id {
151 + font-weight: 600;
152 + font-size: 0.95rem;
153 + font-family: monospace;
154 + }
155 + }
156 +
157 + .badges {
158 + display: flex;
159 + gap: 4px;
160 + }
161 + }
162 +
163 + .card-title {
164 + font-size: 0.875rem;
165 + line-height: 1.4;
166 + margin-bottom: 12px;
167 + opacity: 0.9;
168 + display: -webkit-box;
169 + -webkit-line-clamp: 2;
170 + -webkit-box-orient: vertical;
171 + overflow: hidden;
172 + }
173 +
174 + .product-info {
175 + display: flex;
176 + align-items: center;
177 + gap: 8px;
178 + margin-bottom: 12px;
179 +
180 + .product-name {
181 + font-size: 0.8rem;
182 + opacity: 0.7;
183 + }
184 + }
185 +
186 + .scores-row {
187 + display: flex;
188 + gap: 16px;
189 + margin-bottom: 12px;
190 +
191 + .score-item {
192 + display: flex;
193 + flex-direction: column;
194 +
195 + .score-label {
196 + font-size: 0.7rem;
197 + text-transform: uppercase;
198 + opacity: 0.6;
199 + }
200 +
201 + .score-value {
202 + font-weight: 600;
203 + font-size: 0.9rem;
204 +
205 + &.critical {
206 + color: #ef4444;
207 + }
208 + &.high {
209 + color: #f97316;
210 + }
211 + &.medium {
212 + color: #eab308;
213 + }
214 + &.low {
215 + color: #22c55e;
216 + }
217 + }
218 + }
219 + }
220 +
221 + .kb-row {
222 + display: flex;
223 + align-items: center;
224 + gap: 6px;
225 + font-size: 0.8rem;
226 + opacity: 0.7;
227 + margin-bottom: 8px;
228 +
229 + .kb-more {
230 + opacity: 0.6;
231 + }
232 + }
233 +
234 + .sla-hint {
235 + display: flex;
236 + align-items: center;
237 + gap: 6px;
238 + font-size: 0.75rem;
239 + opacity: 0.6;
240 + padding-top: 8px;
241 + border-top: 1px solid var(--border-color);
242 + }
243 +}
244 +</style>
frontend/src/components/patchTuesday/PatchTuesdayDetail.vue new
+359
@@ -0,0 +1,359 @@
1 +<template>
2 + <div class="patch-tuesday-detail">
3 + <!-- Header Section -->
4 + <div class="detail-header">
5 + <div class="header-row">
6 + <PatchTuesdayPriorityBadge :priority="item.prioritization.priority" />
7 + <n-tag v-if="item.kev.in_kev" type="error" size="small">
8 + <template #icon>
9 + <Icon :name="AlertIcon" />
10 + </template>
11 + Known Exploited
12 + </n-tag>
13 + <n-tag v-if="item.severity" :type="getSeverityType(item.severity)" size="small">
14 + {{ item.severity }}
15 + </n-tag>
16 + </div>
17 + <h2 class="detail-title">{{ item.title || "No title available" }}</h2>
18 + </div>
19 +
20 + <n-divider />
21 +
22 + <!-- Scores Section -->
23 + <div class="detail-section">
24 + <h3 class="section-title">Risk Scores</h3>
25 + <div class="scores-grid">
26 + <div v-if="item.cvss.base !== null" class="score-card">
27 + <span class="score-label">CVSS Base Score</span>
28 + <span class="score-value" :class="getCvssClass(item.cvss.base)">
29 + {{ item.cvss.base.toFixed(1) }}
30 + </span>
31 + <span v-if="item.cvss.vector" class="score-detail">{{ item.cvss.vector }}</span>
32 + </div>
33 + <div v-if="item.epss.score !== null" class="score-card">
34 + <span class="score-label">EPSS Score</span>
35 + <span class="score-value">{{ (item.epss.score * 100).toFixed(2) }}%</span>
36 + <span class="score-detail">Probability of exploitation</span>
37 + </div>
38 + <div v-if="item.epss.percentile !== null" class="score-card">
39 + <span class="score-label">EPSS Percentile</span>
40 + <span class="score-value">{{ (item.epss.percentile * 100).toFixed(0) }}%</span>
41 + <span class="score-detail">Higher than {{ (item.epss.percentile * 100).toFixed(0) }}% of CVEs</span>
42 + </div>
43 + </div>
44 + </div>
45 +
46 + <n-divider />
47 +
48 + <!-- Prioritization Section -->
49 + <div class="detail-section">
50 + <h3 class="section-title">Prioritization</h3>
51 + <n-alert :type="getPriorityAlertType(item.prioritization.priority)" class="mb-3">
52 + <template #header>{{ item.prioritization.suggested_sla }}</template>
53 + </n-alert>
54 + <div class="reasons-list">
55 + <strong>Priority Factors:</strong>
56 + <ul>
57 + <li v-for="reason in item.prioritization.reason" :key="reason">{{ reason }}</li>
58 + </ul>
59 + </div>
60 + </div>
61 +
62 + <n-divider />
63 +
64 + <!-- Affected Product Section -->
65 + <div class="detail-section">
66 + <h3 class="section-title">Affected Product</h3>
67 + <n-descriptions :column="1" label-placement="left" bordered size="small">
68 + <n-descriptions-item label="Product">
69 + {{ item.affected.product }}
70 + </n-descriptions-item>
71 + <n-descriptions-item label="Family">
72 + <n-tag size="small">{{ item.affected.family }}</n-tag>
73 + </n-descriptions-item>
74 + </n-descriptions>
75 + </div>
76 +
77 + <!-- KEV Details (if applicable) -->
78 + <template v-if="item.kev.in_kev">
79 + <n-divider />
80 + <div class="detail-section">
81 + <h3 class="section-title">
82 + <Icon :name="AlertIcon" class="text-error mr-2" />
83 + CISA KEV Details
84 + </h3>
85 + <n-alert type="error" class="mb-3">
86 + This vulnerability is in the CISA Known Exploited Vulnerabilities catalog and is actively being
87 + exploited in the wild.
88 + </n-alert>
89 + <n-descriptions :column="1" label-placement="left" bordered size="small">
90 + <n-descriptions-item v-if="item.kev.date_added" label="Date Added">
91 + {{ formatDate(item.kev.date_added) }}
92 + </n-descriptions-item>
93 + <n-descriptions-item v-if="item.kev.due_date" label="Remediation Due">
94 + <n-tag type="error" size="small">{{ formatDate(item.kev.due_date) }}</n-tag>
95 + </n-descriptions-item>
96 + <n-descriptions-item v-if="item.kev.required_action" label="Required Action">
97 + {{ item.kev.required_action }}
98 + </n-descriptions-item>
99 + <n-descriptions-item v-if="item.kev.known_ransomware_campaign_use" label="Ransomware Use">
100 + {{ item.kev.known_ransomware_campaign_use }}
101 + </n-descriptions-item>
102 + <n-descriptions-item v-if="item.kev.short_description" label="Description">
103 + {{ item.kev.short_description }}
104 + </n-descriptions-item>
105 + </n-descriptions>
106 + </div>
107 + </template>
108 +
109 + <n-divider />
110 +
111 + <!-- Remediation Section -->
112 + <div class="detail-section">
113 + <h3 class="section-title">Remediation</h3>
114 + <div v-if="item.remediation.kbs.length > 0" class="kb-list">
115 + <strong>Related KB Articles:</strong>
116 + <div class="kb-tags">
117 + <n-tag
118 + v-for="kb in item.remediation.kbs"
119 + :key="kb"
120 + size="small"
121 + class="kb-tag"
122 + @click="openKB(kb)"
123 + >
124 + <template #icon>
125 + <Icon :name="ExternalLinkIcon" />
126 + </template>
127 + {{ kb }}
128 + </n-tag>
129 + </div>
130 + </div>
131 + <n-empty v-else description="No KB articles available" size="small" />
132 + </div>
133 +
134 + <n-divider />
135 +
136 + <!-- Source Section -->
137 + <div class="detail-section">
138 + <h3 class="section-title">Sources</h3>
139 + <div class="source-links">
140 + <n-button
141 + text
142 + tag="a"
143 + :href="item.source.msrc_cvrf_url"
144 + target="_blank"
145 + type="primary"
146 + >
147 + <template #icon>
148 + <Icon :name="ExternalLinkIcon" />
149 + </template>
150 + MSRC Security Update
151 + </n-button>
152 + <n-button
153 + text
154 + tag="a"
155 + :href="`https://nvd.nist.gov/vuln/detail/${item.cve}`"
156 + target="_blank"
157 + type="primary"
158 + >
159 + <template #icon>
160 + <Icon :name="ExternalLinkIcon" />
161 + </template>
162 + NVD Entry
163 + </n-button>
164 + </div>
165 + </div>
166 +
167 + <!-- Metadata Footer -->
168 + <div class="detail-footer">
169 + <span>Cycle: {{ item.cycle }}</span>
170 + <span>•</span>
171 + <span>Updated: {{ formatDateTime(item.timestamp_utc) }}</span>
172 + </div>
173 + </div>
174 +</template>
175 +
176 +<script setup lang="ts">
177 +import type { PatchTuesdayItem } from "@/types/patchTuesday.d"
178 +import {
179 + NAlert,
180 + NButton,
181 + NDescriptions,
182 + NDescriptionsItem,
183 + NDivider,
184 + NEmpty,
185 + NTag
186 +} from "naive-ui"
187 +import Icon from "@/components/common/Icon.vue"
188 +import { PriorityLevel } from "@/types/patchTuesday.d"
189 +import PatchTuesdayPriorityBadge from "./PatchTuesdayPriorityBadge.vue"
190 +
191 +defineProps<{
192 + item: PatchTuesdayItem
193 +}>()
194 +const AlertIcon = "carbon:warning"
195 +const ExternalLinkIcon = "carbon:launch"
196 +
197 +function getSeverityType(severity: string): "error" | "warning" | "info" | "default" {
198 + const s = severity.toLowerCase()
199 + if (s === "critical") return "error"
200 + if (s === "important") return "warning"
201 + if (s === "moderate") return "info"
202 + return "default"
203 +}
204 +
205 +function getCvssClass(score: number): string {
206 + if (score >= 9.0) return "critical"
207 + if (score >= 7.0) return "high"
208 + if (score >= 4.0) return "medium"
209 + return "low"
210 +}
211 +
212 +function getPriorityAlertType(priority: PriorityLevel | string): "error" | "warning" | "info" | "success" {
213 + switch (priority) {
214 + case PriorityLevel.P0:
215 + return "error"
216 + case PriorityLevel.P1:
217 + return "warning"
218 + case PriorityLevel.P2:
219 + return "info"
220 + case PriorityLevel.P3:
221 + return "success"
222 + default:
223 + return "info"
224 + }
225 +}
226 +
227 +function formatDate(dateStr: string): string {
228 + if (!dateStr) return "-"
229 + return new Date(dateStr).toLocaleDateString("en-US", {
230 + year: "numeric",
231 + month: "long",
232 + day: "numeric"
233 + })
234 +}
235 +
236 +function formatDateTime(dateStr: string): string {
237 + if (!dateStr) return "-"
238 + return new Date(dateStr).toLocaleString()
239 +}
240 +
241 +function openKB(kb: string) {
242 + window.open(`https://support.microsoft.com/help/${kb.replace("KB", "")}`, "_blank")
243 +}
244 +</script>
245 +
246 +<style scoped lang="scss">
247 +.patch-tuesday-detail {
248 + .detail-header {
249 + .header-row {
250 + display: flex;
251 + gap: 8px;
252 + margin-bottom: 12px;
253 + }
254 +
255 + .detail-title {
256 + font-size: 1.1rem;
257 + font-weight: 600;
258 + line-height: 1.4;
259 + }
260 + }
261 +
262 + .detail-section {
263 + .section-title {
264 + font-size: 0.9rem;
265 + font-weight: 600;
266 + margin-bottom: 12px;
267 + display: flex;
268 + align-items: center;
269 + }
270 + }
271 +
272 + .scores-grid {
273 + display: grid;
274 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
275 + gap: 12px;
276 +
277 + .score-card {
278 + background: var(--bg-secondary-color);
279 + padding: 12px;
280 + border-radius: 8px;
281 + display: flex;
282 + flex-direction: column;
283 +
284 + .score-label {
285 + font-size: 0.75rem;
286 + opacity: 0.7;
287 + margin-bottom: 4px;
288 + }
289 +
290 + .score-value {
291 + font-size: 1.25rem;
292 + font-weight: 700;
293 +
294 + &.critical {
295 + color: #ef4444;
296 + }
297 + &.high {
298 + color: #f97316;
299 + }
300 + &.medium {
301 + color: #eab308;
302 + }
303 + &.low {
304 + color: #22c55e;
305 + }
306 + }
307 +
308 + .score-detail {
309 + font-size: 0.7rem;
310 + opacity: 0.6;
311 + margin-top: 4px;
312 + }
313 + }
314 + }
315 +
316 + .reasons-list {
317 + ul {
318 + margin: 8px 0 0 20px;
319 + padding: 0;
320 +
321 + li {
322 + margin-bottom: 4px;
323 + font-size: 0.875rem;
324 + }
325 + }
326 + }
327 +
328 + .kb-tags {
329 + display: flex;
330 + flex-wrap: wrap;
331 + gap: 8px;
332 + margin-top: 8px;
333 +
334 + .kb-tag {
335 + cursor: pointer;
336 +
337 + &:hover {
338 + opacity: 0.8;
339 + }
340 + }
341 + }
342 +
343 + .source-links {
344 + display: flex;
345 + flex-direction: column;
346 + gap: 8px;
347 + }
348 +
349 + .detail-footer {
350 + margin-top: 24px;
351 + padding-top: 12px;
352 + border-top: 1px solid var(--border-color);
353 + font-size: 0.75rem;
354 + opacity: 0.6;
355 + display: flex;
356 + gap: 8px;
357 + }
358 +}
359 +</style>
frontend/src/components/patchTuesday/PatchTuesdayFilters.vue new
+201
@@ -0,0 +1,201 @@
1 +<template>
2 + <n-card size="small" :bordered="false" class="filters-card">
3 + <div class="filters-container">
4 + <!-- Cycle Selector -->
5 + <div class="filter-item">
6 + <label class="filter-label">Cycle</label>
7 + <n-select
8 + :value="filters.cycle"
9 + :options="cycleOptions"
10 + :loading="loading"
11 + placeholder="Select cycle"
12 + style="min-width: 140px"
13 + @update:value="updateFilter('cycle', $event)"
14 + />
15 + </div>
16 +
17 + <!-- Priority Filter -->
18 + <div class="filter-item">
19 + <label class="filter-label">Priority</label>
20 + <n-select
21 + :value="filters.priority"
22 + :options="priorityOptions"
23 + clearable
24 + placeholder="All priorities"
25 + style="min-width: 140px"
26 + @update:value="updateFilter('priority', $event)"
27 + />
28 + </div>
29 +
30 + <!-- Family Filter -->
31 + <div class="filter-item">
32 + <label class="filter-label">Product Family</label>
33 + <n-select
34 + :value="filters.family"
35 + :options="familyOptions"
36 + clearable
37 + placeholder="All families"
38 + style="min-width: 160px"
39 + @update:value="updateFilter('family', $event)"
40 + />
41 + </div>
42 +
43 + <!-- Severity Filter -->
44 + <div class="filter-item">
45 + <label class="filter-label">Severity</label>
46 + <n-select
47 + :value="filters.severity"
48 + :options="severityOptions"
49 + clearable
50 + placeholder="All severities"
51 + style="min-width: 140px"
52 + @update:value="updateFilter('severity', $event)"
53 + />
54 + </div>
55 +
56 + <!-- Search -->
57 + <div class="filter-item search-item">
58 + <label class="filter-label">Search</label>
59 + <n-input
60 + :value="filters.searchQuery"
61 + placeholder="CVE, title, or product..."
62 + clearable
63 + style="min-width: 200px"
64 + @update:value="updateFilter('searchQuery', $event)"
65 + >
66 + <template #prefix>
67 + <Icon :name="SearchIcon" />
68 + </template>
69 + </n-input>
70 + </div>
71 +
72 + <!-- KEV Only Toggle -->
73 + <div class="filter-item toggle-item">
74 + <n-tooltip trigger="hover">
75 + <template #trigger>
76 + <n-switch
77 + :value="filters.kevOnly"
78 + @update:value="updateFilter('kevOnly', $event)"
79 + >
80 + <template #checked>KEV</template>
81 + <template #unchecked>KEV</template>
82 + </n-switch>
83 + </template>
84 + Show only Known Exploited Vulnerabilities
85 + </n-tooltip>
86 + </div>
87 +
88 + <!-- Clear Filters -->
89 + <div class="filter-item">
90 + <n-button quaternary size="small" @click="clearFilters">
91 + <template #icon>
92 + <Icon :name="ClearIcon" />
93 + </template>
94 + Clear
95 + </n-button>
96 + </div>
97 + </div>
98 + </n-card>
99 +</template>
100 +
101 +<script setup lang="ts">
102 +import type { PatchTuesdayFilters } from "./types"
103 +import { NButton, NCard, NInput, NSelect, NSwitch, NTooltip } from "naive-ui"
104 +import { computed } from "vue"
105 +import Icon from "@/components/common/Icon.vue"
106 +import { PriorityLevel } from "@/types/patchTuesday.d"
107 +
108 +const props = defineProps<{
109 + filters: PatchTuesdayFilters
110 + cycles: string[]
111 + families: string[]
112 + loading?: boolean
113 +}>()
114 +const emit = defineEmits<{
115 + (e: "update:filters", filters: PatchTuesdayFilters): void
116 +}>()
117 +const SearchIcon = "carbon:search"
118 +const ClearIcon = "carbon:close"
119 +
120 +const cycleOptions = computed(() =>
121 + props.cycles.map(cycle => ({
122 + label: cycle,
123 + value: cycle
124 + }))
125 +)
126 +
127 +const priorityOptions = [
128 + { label: "P0 - Emergency", value: PriorityLevel.P0 },
129 + { label: "P1 - High", value: PriorityLevel.P1 },
130 + { label: "P2 - Medium", value: PriorityLevel.P2 },
131 + { label: "P3 - Low", value: PriorityLevel.P3 }
132 +]
133 +
134 +const familyOptions = computed(() =>
135 + props.families.map(family => ({
136 + label: family,
137 + value: family
138 + }))
139 +)
140 +
141 +const severityOptions = [
142 + { label: "Critical", value: "critical" },
143 + { label: "Important", value: "important" },
144 + { label: "Moderate", value: "moderate" },
145 + { label: "Low", value: "low" }
146 +]
147 +
148 +function updateFilter<K extends keyof PatchTuesdayFilters>(key: K, value: PatchTuesdayFilters[K]) {
149 + emit("update:filters", {
150 + ...props.filters,
151 + [key]: value
152 + })
153 +}
154 +
155 +function clearFilters() {
156 + emit("update:filters", {
157 + cycle: props.filters.cycle, // Keep cycle selected
158 + priority: null,
159 + family: null,
160 + severity: null,
161 + searchQuery: "",
162 + kevOnly: false
163 + })
164 +}
165 +</script>
166 +
167 +<style scoped lang="scss">
168 +.filters-card {
169 + background: var(--bg-secondary-color);
170 + border-radius: 8px;
171 +
172 + .filters-container {
173 + display: flex;
174 + flex-wrap: wrap;
175 + align-items: flex-end;
176 + gap: 16px;
177 + }
178 +
179 + .filter-item {
180 + display: flex;
181 + flex-direction: column;
182 + gap: 4px;
183 +
184 + .filter-label {
185 + font-size: 0.75rem;
186 + text-transform: uppercase;
187 + letter-spacing: 0.5px;
188 + opacity: 0.7;
189 + }
190 +
191 + &.search-item {
192 + flex: 1;
193 + min-width: 200px;
194 + }
195 +
196 + &.toggle-item {
197 + padding-bottom: 4px;
198 + }
199 + }
200 +}
201 +</style>
frontend/src/components/patchTuesday/PatchTuesdayList.vue new
+237
@@ -0,0 +1,237 @@
1 +<template>
2 + <div class="patch-tuesday-list">
3 + <!-- Header -->
4 + <div class="header flex items-center justify-between gap-4 mb-4">
5 + <div class="flex items-center gap-3">
6 + <Icon :name="CalendarIcon" :size="26" class="text-primary-color" />
7 + <h1 class="text-2xl font-bold">Microsoft Patch Tuesday</h1>
8 + </div>
9 + <div class="flex items-center gap-2">
10 + <n-button
11 + :loading="loading"
12 + :disabled="loading"
13 + type="primary"
14 + secondary
15 + @click="fetchData"
16 + >
17 + <template #icon>
18 + <Icon :name="RefreshIcon" />
19 + </template>
20 + Refresh
21 + </n-button>
22 + </div>
23 + </div>
24 +
25 + <!-- Stats Cards -->
26 + <PatchTuesdayStats :summary="summary" :loading="loading" class="mb-4" />
27 +
28 + <!-- Filters -->
29 + <PatchTuesdayFilters
30 + v-model:filters="filters"
31 + :cycles="availableCycles"
32 + :families="availableFamilies"
33 + :loading="loading"
34 + class="mb-4"
35 + @update:filters="handleFiltersChange"
36 + />
37 +
38 + <!-- Items List -->
39 + <n-spin :show="loading">
40 + <div v-if="filteredItems.length > 0" class="items-grid">
41 + <PatchTuesdayCard
42 + v-for="item in paginatedItems"
43 + :key="`${item.cve}-${item.affected.product}`"
44 + :item="item"
45 + @click="openItemDetail(item)"
46 + />
47 + </div>
48 +
49 + <n-empty
50 + v-else-if="!loading"
51 + description="No vulnerabilities found for the selected filters"
52 + class="py-12"
53 + />
54 + </n-spin>
55 +
56 + <!-- Pagination -->
57 + <div v-if="filteredItems.length > pageSize" class="flex justify-center mt-4">
58 + <n-pagination
59 + v-model:page="currentPage"
60 + :page-count="totalPages"
61 + :page-size="pageSize"
62 + show-quick-jumper
63 + />
64 + </div>
65 +
66 + <!-- Detail Drawer -->
67 + <n-drawer v-model:show="showDetail" :width="600" placement="right">
68 + <n-drawer-content :title="selectedItem?.cve || 'Vulnerability Details'" closable>
69 + <PatchTuesdayDetail v-if="selectedItem" :item="selectedItem" />
70 + </n-drawer-content>
71 + </n-drawer>
72 + </div>
73 +</template>
74 +
75 +<script setup lang="ts">
76 +import type { PatchTuesdayFilters as FiltersType } from "./types"
77 +import type { PatchTuesdayItem, PatchTuesdaySummary } from "@/types/patchTuesday.d"
78 +import { NButton, NDrawer, NDrawerContent, NEmpty, NPagination, NSpin, useMessage } from "naive-ui"
79 +import { computed, onMounted, ref, watch } from "vue"
80 +import patchTuesdayApi from "@/api/endpoints/patchTuesday"
81 +import Icon from "@/components/common/Icon.vue"
82 +import PatchTuesdayCard from "./PatchTuesdayCard.vue"
83 +import PatchTuesdayDetail from "./PatchTuesdayDetail.vue"
84 +import PatchTuesdayFilters from "./PatchTuesdayFilters.vue"
85 +import PatchTuesdayStats from "./PatchTuesdayStats.vue"
86 +
87 +const CalendarIcon = "carbon:calendar"
88 +const RefreshIcon = "carbon:refresh"
89 +
90 +const message = useMessage()
91 +
92 +// State
93 +const loading = ref(false)
94 +const items = ref<PatchTuesdayItem[]>([])
95 +const summary = ref<PatchTuesdaySummary | null>(null)
96 +const availableCycles = ref<string[]>([])
97 +const currentPage = ref(1)
98 +const pageSize = 24
99 +const showDetail = ref(false)
100 +const selectedItem = ref<PatchTuesdayItem | null>(null)
101 +
102 +const filters = ref<FiltersType>({
103 + cycle: "",
104 + priority: null,
105 + family: null,
106 + severity: null,
107 + searchQuery: "",
108 + kevOnly: false
109 +})
110 +
111 +// Computed
112 +const availableFamilies = computed(() => {
113 + if (!summary.value?.by_family) return []
114 + return Object.keys(summary.value.by_family).sort()
115 +})
116 +
117 +const filteredItems = computed(() => {
118 + let result = [...items.value]
119 +
120 + // Filter by priority
121 + if (filters.value.priority) {
122 + result = result.filter(item => item.prioritization.priority === filters.value.priority)
123 + }
124 +
125 + // Filter by family
126 + if (filters.value.family) {
127 + result = result.filter(item => item.affected.family === filters.value.family)
128 + }
129 +
130 + // Filter by severity
131 + if (filters.value.severity) {
132 + result = result.filter(item => item.severity?.toLowerCase() === filters.value.severity?.toLowerCase())
133 + }
134 +
135 + // Filter KEV only
136 + if (filters.value.kevOnly) {
137 + result = result.filter(item => item.kev.in_kev)
138 + }
139 +
140 + // Search filter
141 + if (filters.value.searchQuery) {
142 + const query = filters.value.searchQuery.toLowerCase()
143 + result = result.filter(
144 + item =>
145 + item.cve.toLowerCase().includes(query) ||
146 + item.title?.toLowerCase().includes(query) ||
147 + item.affected.product.toLowerCase().includes(query)
148 + )
149 + }
150 +
151 + return result
152 +})
153 +
154 +const totalPages = computed(() => Math.ceil(filteredItems.value.length / pageSize))
155 +
156 +const paginatedItems = computed(() => {
157 + const start = (currentPage.value - 1) * pageSize
158 + return filteredItems.value.slice(start, start + pageSize)
159 +})
160 +
161 +// Methods
162 +async function fetchCycles() {
163 + try {
164 + const response = await patchTuesdayApi.getCycles()
165 + if (response.data.success) {
166 + availableCycles.value = response.data.cycles
167 + if (!filters.value.cycle && response.data.current_cycle) {
168 + filters.value.cycle = response.data.current_cycle
169 + }
170 + }
171 + } catch (error) {
172 + console.error("Failed to fetch cycles:", error)
173 + }
174 +}
175 +
176 +async function fetchData() {
177 + if (!filters.value.cycle) return
178 +
179 + loading.value = true
180 + try {
181 + const response = await patchTuesdayApi.getPatchTuesday({
182 + cycle: filters.value.cycle,
183 + include_epss: true,
184 + include_kev: true
185 + })
186 +
187 + if (response.data.success) {
188 + items.value = response.data.items
189 + summary.value = response.data.summary
190 + } else {
191 + message.error(response.data.message || "Failed to fetch Patch Tuesday data")
192 + }
193 + } catch (error: unknown) {
194 + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
195 + message.error(`Error fetching data: ${errorMessage}`)
196 + } finally {
197 + loading.value = false
198 + }
199 +}
200 +
201 +function handleFiltersChange() {
202 + currentPage.value = 1
203 + if (filters.value.cycle) {
204 + fetchData()
205 + }
206 +}
207 +
208 +function openItemDetail(item: PatchTuesdayItem) {
209 + selectedItem.value = item
210 + showDetail.value = true
211 +}
212 +
213 +// Watch for cycle changes
214 +watch(
215 + () => filters.value.cycle,
216 + newCycle => {
217 + if (newCycle) {
218 + fetchData()
219 + }
220 + }
221 +)
222 +
223 +// Lifecycle
224 +onMounted(async () => {
225 + await fetchCycles()
226 +})
227 +</script>
228 +
229 +<style scoped lang="scss">
230 +.patch-tuesday-list {
231 + .items-grid {
232 + display: grid;
233 + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
234 + gap: 16px;
235 + }
236 +}
237 +</style>
frontend/src/components/patchTuesday/PatchTuesdayPriorityBadge.vue new
+52
@@ -0,0 +1,52 @@
1 +<template>
2 + <n-tag :type="tagType" size="small" round :bordered="false" class="priority-badge">
3 + {{ label }}
4 + </n-tag>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import { NTag } from "naive-ui"
9 +import { computed } from "vue"
10 +import { PriorityLevel } from "@/types/patchTuesday.d"
11 +
12 +const props = defineProps<{
13 + priority: PriorityLevel | string
14 +}>()
15 +
16 +const tagType = computed(() => {
17 + switch (props.priority) {
18 + case PriorityLevel.P0:
19 + return "error"
20 + case PriorityLevel.P1:
21 + return "warning"
22 + case PriorityLevel.P2:
23 + return "info"
24 + case PriorityLevel.P3:
25 + return "success"
26 + default:
27 + return "default"
28 + }
29 +})
30 +
31 +const label = computed(() => {
32 + switch (props.priority) {
33 + case PriorityLevel.P0:
34 + return "P0"
35 + case PriorityLevel.P1:
36 + return "P1"
37 + case PriorityLevel.P2:
38 + return "P2"
39 + case PriorityLevel.P3:
40 + return "P3"
41 + default:
42 + return props.priority
43 + }
44 +})
45 +</script>
46 +
47 +<style scoped lang="scss">
48 +.priority-badge {
49 + font-weight: 600;
50 + font-size: 0.7rem;
51 +}
52 +</style>
frontend/src/components/patchTuesday/PatchTuesdayStats.vue new
+216
@@ -0,0 +1,216 @@
1 +<template>
2 + <div class="patch-tuesday-stats">
3 + <n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:4 l:5">
4 + <!-- Total CVEs -->
5 + <n-grid-item>
6 + <n-card size="small" :bordered="false" class="stat-card">
7 + <div class="stat-content">
8 + <div class="stat-icon total">
9 + <Icon :name="ShieldIcon" :size="24" />
10 + </div>
11 + <div class="stat-info">
12 + <span class="stat-value">{{ summary?.unique_cves ?? "-" }}</span>
13 + <span class="stat-label">Unique CVEs</span>
14 + </div>
15 + </div>
16 + </n-card>
17 + </n-grid-item>
18 +
19 + <!-- P0 Emergency -->
20 + <n-grid-item>
21 + <n-card size="small" :bordered="false" class="stat-card priority-p0">
22 + <div class="stat-content">
23 + <div class="stat-icon p0">
24 + <Icon :name="AlertIcon" :size="24" />
25 + </div>
26 + <div class="stat-info">
27 + <span class="stat-value">{{ summary?.by_priority?.P0 ?? 0 }}</span>
28 + <span class="stat-label">P0 Emergency</span>
29 + </div>
30 + </div>
31 + </n-card>
32 + </n-grid-item>
33 +
34 + <!-- P1 High -->
35 + <n-grid-item>
36 + <n-card size="small" :bordered="false" class="stat-card priority-p1">
37 + <div class="stat-content">
38 + <div class="stat-icon p1">
39 + <Icon :name="UrgentIcon" :size="24" />
40 + </div>
41 + <div class="stat-info">
42 + <span class="stat-value">{{ summary?.by_priority?.P1 ?? 0 }}</span>
43 + <span class="stat-label">P1 High</span>
44 + </div>
45 + </div>
46 + </n-card>
47 + </n-grid-item>
48 +
49 + <!-- P2 Medium -->
50 + <n-grid-item>
51 + <n-card size="small" :bordered="false" class="stat-card priority-p2">
52 + <div class="stat-content">
53 + <div class="stat-icon p2">
54 + <Icon :name="InfoIcon" :size="24" />
55 + </div>
56 + <div class="stat-info">
57 + <span class="stat-value">{{ summary?.by_priority?.P2 ?? 0 }}</span>
58 + <span class="stat-label">P2 Medium</span>
59 + </div>
60 + </div>
61 + </n-card>
62 + </n-grid-item>
63 +
64 + <!-- P3 Low -->
65 + <n-grid-item>
66 + <n-card size="small" :bordered="false" class="stat-card priority-p3">
67 + <div class="stat-content">
68 + <div class="stat-icon p3">
69 + <Icon :name="CheckIcon" :size="24" />
70 + </div>
71 + <div class="stat-info">
72 + <span class="stat-value">{{ summary?.by_priority?.P3 ?? 0 }}</span>
73 + <span class="stat-label">P3 Low</span>
74 + </div>
75 + </div>
76 + </n-card>
77 + </n-grid-item>
78 + </n-grid>
79 +
80 + <!-- Additional Info Bar -->
81 + <div v-if="summary" class="info-bar mt-4">
82 + <n-space :size="24">
83 + <span class="info-item">
84 + <Icon :name="CalendarIcon" class="mr-1" />
85 + Patch Tuesday: <strong>{{ formatDate(summary.patch_tuesday_date) }}</strong>
86 + </span>
87 + <span class="info-item">
88 + <Icon :name="DatabaseIcon" class="mr-1" />
89 + Total Records: <strong>{{ summary.total_records }}</strong>
90 + </span>
91 + <span class="info-item">
92 + <Icon :name="ClockIcon" class="mr-1" />
93 + Generated: <strong>{{ formatDateTime(summary.generated_utc) }}</strong>
94 + </span>
95 + </n-space>
96 + </div>
97 + </div>
98 +</template>
99 +
100 +<script setup lang="ts">
101 +import type { PatchTuesdaySummary } from "@/types/patchTuesday.d"
102 +import { NCard, NGrid, NGridItem, NSpace } from "naive-ui"
103 +import Icon from "@/components/common/Icon.vue"
104 +
105 +defineProps<{
106 + summary: PatchTuesdaySummary | null
107 + loading?: boolean
108 +}>()
109 +const AlertIcon = "carbon:warning"
110 +const CalendarIcon = "carbon:calendar"
111 +const CheckIcon = "carbon:checkmark-filled"
112 +const ClockIcon = "carbon:time"
113 +const DatabaseIcon = "carbon:data-base"
114 +const InfoIcon = "carbon:information"
115 +const ShieldIcon = "carbon:security"
116 +const UrgentIcon = "carbon:warning-hex"
117 +
118 +function formatDate(dateStr: string): string {
119 + if (!dateStr) return "-"
120 + return new Date(dateStr).toLocaleDateString("en-US", {
121 + year: "numeric",
122 + month: "long",
123 + day: "numeric"
124 + })
125 +}
126 +
127 +function formatDateTime(dateStr: string): string {
128 + if (!dateStr) return "-"
129 + return new Date(dateStr).toLocaleString("en-US", {
130 + month: "short",
131 + day: "numeric",
132 + hour: "2-digit",
133 + minute: "2-digit"
134 + })
135 +}
136 +</script>
137 +
138 +<style scoped lang="scss">
139 +.patch-tuesday-stats {
140 + .stat-card {
141 + border-radius: 8px;
142 + background: var(--bg-secondary-color);
143 +
144 + .stat-content {
145 + display: flex;
146 + align-items: center;
147 + gap: 12px;
148 + }
149 +
150 + .stat-icon {
151 + width: 48px;
152 + height: 48px;
153 + border-radius: 8px;
154 + display: flex;
155 + align-items: center;
156 + justify-content: center;
157 +
158 + &.total {
159 + background: rgba(99, 102, 241, 0.15);
160 + color: #6366f1;
161 + }
162 +
163 + &.p0 {
164 + background: rgba(239, 68, 68, 0.15);
165 + color: #ef4444;
166 + }
167 +
168 + &.p1 {
169 + background: rgba(249, 115, 22, 0.15);
170 + color: #f97316;
171 + }
172 +
173 + &.p2 {
174 + background: rgba(234, 179, 8, 0.15);
175 + color: #eab308;
176 + }
177 +
178 + &.p3 {
179 + background: rgba(34, 197, 94, 0.15);
180 + color: #22c55e;
181 + }
182 + }
183 +
184 + .stat-info {
185 + display: flex;
186 + flex-direction: column;
187 +
188 + .stat-value {
189 + font-size: 1.5rem;
190 + font-weight: 700;
191 + line-height: 1.2;
192 + }
193 +
194 + .stat-label {
195 + font-size: 0.75rem;
196 + opacity: 0.7;
197 + text-transform: uppercase;
198 + letter-spacing: 0.5px;
199 + }
200 + }
201 + }
202 +
203 + .info-bar {
204 + padding: 12px 16px;
205 + background: var(--bg-secondary-color);
206 + border-radius: 8px;
207 +
208 + .info-item {
209 + display: inline-flex;
210 + align-items: center;
211 + font-size: 0.875rem;
212 + opacity: 0.8;
213 + }
214 + }
215 +}
216 +</style>
frontend/src/components/patchTuesday/index.ts new
+6
@@ -0,0 +1,6 @@
1 +export { default as PatchTuesdayCard } from "./PatchTuesdayCard.vue"
2 +export { default as PatchTuesdayDetail } from "./PatchTuesdayDetail.vue"
3 +export { default as PatchTuesdayFilters } from "./PatchTuesdayFilters.vue"
4 +export { default as PatchTuesdayList } from "./PatchTuesdayList.vue"
5 +export { default as PatchTuesdayPriorityBadge } from "./PatchTuesdayPriorityBadge.vue"
6 +export { default as PatchTuesdayStats } from "./PatchTuesdayStats.vue"
frontend/src/components/patchTuesday/types.d.ts new
+23
@@ -0,0 +1,23 @@
1 +import type { PatchTuesdayItem, PatchTuesdaySummary, PriorityLevel } from "@/types/patchTuesday.d"
2 +
3 +export interface PatchTuesdayFilters {
4 + cycle: string
5 + priority: PriorityLevel | null
6 + family: string | null
7 + severity: string | null
8 + searchQuery: string
9 + kevOnly: boolean
10 +}
11 +
12 +export interface PatchTuesdayListEmits {
13 + (e: "item-click", item: PatchTuesdayItem): void
14 +}
15 +
16 +export interface PatchTuesdayStatsProps {
17 + summary: PatchTuesdaySummary | null
18 + loading?: boolean
19 +}
20 +
21 +export interface PatchTuesdayItemProps {
22 + item: PatchTuesdayItem
23 +}
frontend/src/router/index.ts
+7 -1
@@ -101,7 +101,13 @@ const router = createRouter({
101 name: "ScaOverview",
102 component: () => import("@/views/agents/ScaOverview.vue"),
103 meta: { title: "SCA Overview" }
104 - }
104 + },
105 + {
106 + path: "/patch-tuesday",
107 + name: "PatchTuesday",
108 + component: () => import("@/views/agents/PatchTuesdayOverview.vue"),
109 + meta: { title: "Patch Tuesday" }
110 + }
111 ]
112 },
113 {
frontend/src/types/patchTuesday.d.ts new
+148
@@ -0,0 +1,148 @@
1 +export interface CVSSInfo {
2 + base: number | null
3 + vector: string | null
4 +}
5 +
6 +export interface EPSSInfo {
7 + score: number | null
8 + percentile: number | null
9 + date: string | null
10 +}
11 +
12 +export interface KEVInfo {
13 + in_kev: boolean
14 + date_added: string | null
15 + due_date: string | null
16 + required_action: string | null
17 + known_ransomware_campaign_use: string | null
18 + vendor_project: string | null
19 + product: string | null
20 + vulnerability_name: string | null
21 + short_description: string | null
22 + notes: string | null
23 +}
24 +
25 +export interface AffectedProduct {
26 + product: string
27 + family: ProductFamily
28 + component_hint: string | null
29 +}
30 +
31 +export interface RemediationInfo {
32 + kbs: string[]
33 +}
34 +
35 +export interface PrioritizationInfo {
36 + priority: PriorityLevel
37 + reason: string[]
38 + suggested_sla: string
39 +}
40 +
41 +export interface SourceInfo {
42 + msrc_cvrf_id: string
43 + msrc_cvrf_url: string
44 + cisa_kev_url: string
45 +}
46 +
47 +export interface PatchTuesdayItem {
48 + cycle: string
49 + release_type: string
50 + cve: string
51 + title: string | null
52 + severity: string | null
53 + cvss: CVSSInfo
54 + epss: EPSSInfo
55 + kev: KEVInfo
56 + affected: AffectedProduct
57 + remediation: RemediationInfo
58 + prioritization: PrioritizationInfo
59 + source: SourceInfo
60 + timestamp_utc: string
61 +}
62 +
63 +export interface PriorityCounts {
64 + P0: number
65 + P1: number
66 + P2: number
67 + P3: number
68 +}
69 +
70 +export interface PatchTuesdaySummary {
71 + cycle: string
72 + patch_tuesday_date: string
73 + generated_utc: string
74 + unique_cves: number
75 + total_records: number
76 + by_priority: PriorityCounts
77 + by_family: Record<string, number>
78 + by_severity: Record<string, number>
79 +}
80 +
81 +export interface PatchTuesdayResponse {
82 + success: boolean
83 + message: string
84 + summary: PatchTuesdaySummary | null
85 + items: PatchTuesdayItem[]
86 +}
87 +
88 +export interface PatchTuesdaySummaryResponse {
89 + success: boolean
90 + message: string
91 + summary: PatchTuesdaySummary | null
92 + top_items: PatchTuesdayItem[]
93 +}
94 +
95 +export interface AvailableCyclesResponse {
96 + success: boolean
97 + message: string
98 + cycles: string[]
99 + current_cycle: string
100 + next_patch_tuesday: string
101 +}
102 +
103 +export interface PatchTuesdayQuery {
104 + cycle?: string
105 + include_epss?: boolean
106 + include_kev?: boolean
107 +}
108 +
109 +export interface PatchTuesdaySummaryQuery extends PatchTuesdayQuery {
110 + top_n?: number
111 +}
112 +
113 +export interface PatchTuesdaySearchQuery {
114 + cve_ids: string[]
115 + cycle?: string
116 +}
117 +
118 +export interface PatchTuesdayPriorityQuery extends PatchTuesdayQuery {
119 + priority_level: PriorityLevel
120 +}
121 +
122 +export enum PriorityLevel {
123 + P0 = "P0",
124 + P1 = "P1",
125 + P2 = "P2",
126 + P3 = "P3"
127 +}
128 +
129 +export enum ProductFamily {
130 + Windows = "Windows",
131 + WindowsServer = "Windows Server",
132 + OfficeM365 = "Office/M365",
133 + Exchange = "Exchange",
134 + SharePoint = "SharePoint",
135 + SQLServer = "SQL Server",
136 + DeveloperPlatform = "Developer Platform",
137 + Edge = "Edge",
138 + Azure = "Azure",
139 + Dynamics = "Dynamics",
140 + Other = "Other"
141 +}
142 +
143 +export enum MicrosoftSeverity {
144 + Critical = "Critical",
145 + Important = "Important",
146 + Moderate = "Moderate",
147 + Low = "Low"
148 +}
frontend/src/views/agents/PatchTuesdayOverview.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <PatchTuesdayList />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import PatchTuesdayList from "@/components/patchTuesday/PatchTuesdayList.vue"
9 +</script>