portal-mojo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { createIncidentHistoryAdapter, useCan, useMe } from '../../client';
|
|
3
|
+
import { Badge, DetailView, Eyebrow, FlatRow, KnownFieldsCard, RecordFeed, StatusPanel, fmt, toast, type Tone } from '../../ui';
|
|
4
|
+
import { SECURITY_MANAGE_PERMS } from '../security-permissions';
|
|
5
|
+
import { AssistantContextLauncher } from '../assistant/launchers';
|
|
6
|
+
import { EvidenceCard, INCIDENT_METADATA_FIELDS, RequestResponseForensics, TraceForensics, metadataOf } from './forensics';
|
|
7
|
+
import { INCIDENT_LIFECYCLE, IncidentModel, showEventDetail, useIncidentDetail, useIncidentEvents } from './models';
|
|
8
|
+
import { sanitizeIncidentHistoryRow, sanitizeSecurityText } from './sanitize';
|
|
9
|
+
|
|
10
|
+
function priorityTone(priority: number): Tone { return priority >= 8 ? 'danger' : priority >= 5 ? 'warning' : 'info'; }
|
|
11
|
+
function statusTone(status: string): Tone { return ['resolved', 'closed'].includes(status) ? 'success' : ['new', 'open'].includes(status) ? 'danger' : status === 'investigating' ? 'warning' : 'muted'; }
|
|
12
|
+
|
|
13
|
+
export function IncidentDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
14
|
+
const queryClient = useQueryClient();
|
|
15
|
+
const query = useIncidentDetail(id);
|
|
16
|
+
const events = useIncidentEvents(id);
|
|
17
|
+
const save = IncidentModel.useSave();
|
|
18
|
+
const { can: canManage } = useCan(SECURITY_MANAGE_PERMS);
|
|
19
|
+
const { data: me } = useMe();
|
|
20
|
+
if (query.isPending) return <div className="modal-pad dim">Loading incident…</div>;
|
|
21
|
+
if (!query.data || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Incident not found'}</div>;
|
|
22
|
+
const incident = query.data;
|
|
23
|
+
const groupId = Number.isSafeInteger(incident.group_id) && Number(incident.group_id) > 0
|
|
24
|
+
? Number(incident.group_id) : null;
|
|
25
|
+
const adapter = createIncidentHistoryAdapter(id, {
|
|
26
|
+
groupId,
|
|
27
|
+
sanitizeRow: sanitizeIncidentHistoryRow,
|
|
28
|
+
sanitizeText: sanitizeSecurityText,
|
|
29
|
+
});
|
|
30
|
+
const metadata = metadataOf(incident);
|
|
31
|
+
const patchStatus = async (status: string) => {
|
|
32
|
+
try {
|
|
33
|
+
await save.mutateAsync({ id, changes: { status } });
|
|
34
|
+
await queryClient.invalidateQueries({ queryKey: IncidentModel.keys.root });
|
|
35
|
+
toast.success(`Incident moved to ${status}`);
|
|
36
|
+
} catch (error) { toast.error(error instanceof Error ? error.message : 'Status update failed'); }
|
|
37
|
+
};
|
|
38
|
+
const trace = metadata.stack_trace ?? metadata.traceback;
|
|
39
|
+
return <DetailView
|
|
40
|
+
icon="bi-shield-exclamation"
|
|
41
|
+
title={incident.title || `Incident #${incident.id}`}
|
|
42
|
+
subtitle={`Incident #${incident.id} · ${incident.scope} · ${fmt.datetime(incident.created)}`}
|
|
43
|
+
chips={[
|
|
44
|
+
{ text: incident.status.toUpperCase(), tone: statusTone(incident.status) },
|
|
45
|
+
{ text: `P${incident.priority}`, tone: priorityTone(incident.priority) },
|
|
46
|
+
{ text: incident.category, tone: 'muted' },
|
|
47
|
+
...(metadata.do_not_delete ? [{ text: 'PROTECTED', tone: 'warning' as const }] : []),
|
|
48
|
+
]}
|
|
49
|
+
sections={[
|
|
50
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <>
|
|
51
|
+
<StatusPanel tone={statusTone(incident.status)} state={incident.status.toUpperCase()} headline={incident.details || incident.title || 'No incident details'} meta={`${incident.category} · ${incident.scope}`} actions={<div className="assistant-action-row"><AssistantContextLauncher model="incident.Incident" pk={incident.id} />{canManage && <select className="input input-compact" value={incident.status} disabled={save.isPending} onChange={(event) => void patchStatus(event.target.value)} aria-label="Incident lifecycle">{INCIDENT_LIFECYCLE.map((status) => <option key={status} value={status}>{status}</option>)}</select>}</div>} />
|
|
52
|
+
<Eyebrow>Incident facts</Eyebrow>
|
|
53
|
+
<FlatRow label="State"><code>{incident.state || '—'}</code></FlatRow>
|
|
54
|
+
<FlatRow label="Source IP"><code>{incident.source_ip || '—'}</code></FlatRow>
|
|
55
|
+
<FlatRow label="Hostname"><code>{incident.hostname || '—'}</code></FlatRow>
|
|
56
|
+
<FlatRow label="Model">{incident.model_name ? `${incident.model_name} #${incident.model_id ?? '—'}` : '—'}</FlatRow>
|
|
57
|
+
</> },
|
|
58
|
+
{ key: 'evidence', label: 'Evidence', icon: 'bi-funnel', render: () => <EvidenceCard metadata={{ ...metadata, model_name: incident.model_name, model_id: incident.model_id }} /> },
|
|
59
|
+
{ key: 'events', label: 'Events', icon: 'bi-list-ul', render: () => events.isPending ? <p className="dim">Loading events…</p> : events.data?.rows.length ? <div className="incident-events-list">{events.data.rows.map((event) => <button type="button" className="incident-event-row" key={event.id} onClick={() => showEventDetail(event.id)}><Badge tone={event.level >= 8 ? 'danger' : event.level >= 4 ? 'warning' : 'muted'}>L{event.level}</Badge><span><strong>{event.title || event.category}</strong><small>{fmt.datetime(event.created)} · {event.details || 'No details'}</small></span></button>)}</div> : <p className="dim-italic">No events on this incident.</p> },
|
|
60
|
+
{ key: 'request', label: 'Request', icon: 'bi-globe2', render: () => <RequestResponseForensics metadata={metadata} /> },
|
|
61
|
+
...(trace ? [{ key: 'trace', label: 'Stack trace', icon: 'bi-code-square', render: () => <TraceForensics metadata={metadata} /> }] : []),
|
|
62
|
+
{ key: 'history', label: 'History', icon: 'bi-chat-left-text', render: () => <RecordFeed adapter={adapter} variant="compact" showInput={canManage} currentUserId={me?.id ?? null} placeholder="Add an incident note…" attachmentUpload={{ destination: groupId == null ? {} : { groupId, use: 'uploads' }, expectedGroupId: groupId }} /> },
|
|
63
|
+
{ key: 'metadata', label: 'Known fields', icon: 'bi-braces', render: () => <KnownFieldsCard data={metadata} known={[...INCIDENT_METADATA_FIELDS]} showRaw={false} emptyText="No curated forensic metadata." /> },
|
|
64
|
+
]}
|
|
65
|
+
initialSection="overview"
|
|
66
|
+
onClose={onClose}
|
|
67
|
+
/>;
|
|
68
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { createSafeExporter, useCan } from '../../client';
|
|
4
|
+
import { Badge, ModelTable, fmt, modal, type BatchAction, type Column, type FilterDef, type Tone } from '../../ui';
|
|
5
|
+
import { SECURITY_MANAGE_PERMS } from '../security-permissions';
|
|
6
|
+
import { INCIDENT_LIFECYCLE, IncidentModel, buildIncidentMerge, showIncidentDetail, type IncidentRow } from './models';
|
|
7
|
+
import { sanitizeIncidentRow } from './sanitize';
|
|
8
|
+
|
|
9
|
+
function priorityTone(priority: number): Tone { return priority >= 8 ? 'danger' : priority >= 5 ? 'warning' : 'info'; }
|
|
10
|
+
function statusTone(status: string): Tone { return ['resolved', 'closed'].includes(status) ? 'success' : ['new', 'open'].includes(status) ? 'danger' : status === 'investigating' ? 'warning' : 'muted'; }
|
|
11
|
+
const options = (values: readonly string[]) => values.map((value) => ({ value, label: value.replaceAll('_', ' ') }));
|
|
12
|
+
|
|
13
|
+
const INCIDENT_EXPORTER = createSafeExporter<IncidentRow>({
|
|
14
|
+
endpoint: IncidentModel.endpoint, filename: 'security-incidents', sanitizeRow: sanitizeIncidentRow,
|
|
15
|
+
fields: [
|
|
16
|
+
{ key: 'id' }, { key: 'created' }, { key: 'status' }, { key: 'state' }, { key: 'priority' },
|
|
17
|
+
{ key: 'scope' }, { key: 'category' }, { key: 'title' }, { key: 'details' },
|
|
18
|
+
{ key: 'source_ip' }, { key: 'hostname' }, { key: 'group_id' },
|
|
19
|
+
{ key: 'rule_id', value: (row) => row.metadata.rule_id },
|
|
20
|
+
{ key: 'protected', value: (row) => Boolean(row.metadata.do_not_delete) },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function MergeTargetForm({ rows, done }: { rows: IncidentRow[]; done: (value: number | null) => void }) {
|
|
25
|
+
const [target, setTarget] = useState(String(rows[0]?.id ?? ''));
|
|
26
|
+
return <form className="modal-pad" onSubmit={(event) => { event.preventDefault(); done(Number(target)); }}>
|
|
27
|
+
<h2 className="modal-title">Choose merge target</h2>
|
|
28
|
+
<p className="dim">The target survives. Every other selected incident becomes a source and is deleted after its events and tickets move.</p>
|
|
29
|
+
<label className="field"><span className="field-label">Target incident</span><select className="input" value={target} onChange={(event) => setTarget(event.target.value)}>{rows.map((row) => <option key={row.id} value={row.id}>#{row.id} · {row.title || row.details || row.category}</option>)}</select></label>
|
|
30
|
+
<div className="modal-actions"><button type="button" className="btn" onClick={() => done(null)}>Cancel</button><button type="submit" className="btn btn-primary">Merge into target</button></div>
|
|
31
|
+
</form>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function IncidentsPage() {
|
|
35
|
+
const { can: canManage } = useCan(SECURITY_MANAGE_PERMS);
|
|
36
|
+
const queryClient = useQueryClient();
|
|
37
|
+
const save = IncidentModel.useSave();
|
|
38
|
+
const merge = IncidentModel.useAction('merge');
|
|
39
|
+
const lifecycle = (status: string): BatchAction<IncidentRow> => ({
|
|
40
|
+
key: status, label: `Set ${status}`, icon: 'bi-arrow-left-right',
|
|
41
|
+
run: (row) => save.mutateAsync({ id: row.id, changes: { status } }),
|
|
42
|
+
});
|
|
43
|
+
const actions: BatchAction<IncidentRow>[] = [
|
|
44
|
+
...['open', 'investigating', 'resolved', 'closed', 'ignored'].map(lifecycle),
|
|
45
|
+
{
|
|
46
|
+
key: 'protect', label: 'Protect', icon: 'bi-shield-fill-check',
|
|
47
|
+
run: (row) => save.mutateAsync({ id: row.id, changes: { metadata: { ...row.metadata, do_not_delete: true } } }),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: 'merge', label: 'Merge', icon: 'bi-union', danger: true,
|
|
51
|
+
prepare: async (rows) => {
|
|
52
|
+
if (rows.length < 2) throw new Error('Select at least two incidents to merge.');
|
|
53
|
+
return modal.open<number | null>((done) => <MergeTargetForm rows={rows} done={done} />, { size: 'sm' });
|
|
54
|
+
},
|
|
55
|
+
runBatch: async (rows, prepared) => {
|
|
56
|
+
const spec = buildIncidentMerge(rows, Number(prepared));
|
|
57
|
+
try {
|
|
58
|
+
await merge.mutateAsync({ id: spec.target.id, payload: spec.sourceIds });
|
|
59
|
+
} finally {
|
|
60
|
+
for (const id of spec.sourceIds) {
|
|
61
|
+
queryClient.removeQueries({ queryKey: IncidentModel.keys.one(id) });
|
|
62
|
+
queryClient.removeQueries({ queryKey: ['record-feed', 'incident-history', id] });
|
|
63
|
+
}
|
|
64
|
+
await Promise.all([
|
|
65
|
+
queryClient.invalidateQueries({ queryKey: IncidentModel.keys.root }),
|
|
66
|
+
queryClient.invalidateQueries({ queryKey: ['/api/incident/event'] }),
|
|
67
|
+
queryClient.invalidateQueries({ queryKey: ['record-feed', 'incident-history', spec.target.id] }),
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
const columns: Column<IncidentRow>[] = [
|
|
74
|
+
{ key: 'id', label: 'ID', sortable: true, align: 'end', render: (row) => `#${row.id}` },
|
|
75
|
+
{ key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={statusTone(row.status)}>{row.status}</Badge> },
|
|
76
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.datetime(row.created) },
|
|
77
|
+
{ key: 'scope', label: 'Scope', sortable: true },
|
|
78
|
+
{ key: 'category', label: 'Category', sortable: true, render: (row) => <code>{row.category}</code> },
|
|
79
|
+
{ key: 'priority', label: 'Priority', sortable: true, align: 'center', render: (row) => <Badge tone={priorityTone(row.priority)}>P{row.priority}</Badge> },
|
|
80
|
+
{ key: 'title', label: 'Details', sortable: true, hideable: false, render: (row) => <span title={row.details ?? undefined}>{fmt.truncate(row.title || row.details || 'No details', 100)}</span> },
|
|
81
|
+
];
|
|
82
|
+
const filters: FilterDef[] = [
|
|
83
|
+
{ key: 'status', label: 'Lifecycle', type: 'multiselect', options: options(INCIDENT_LIFECYCLE) },
|
|
84
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
85
|
+
{ key: 'scope', label: 'Scope', type: 'text' }, { key: 'category', label: 'Category', type: 'text' },
|
|
86
|
+
{ key: 'priority', label: 'Minimum priority', type: 'number', lookup: 'gte' },
|
|
87
|
+
{ key: 'priority', label: 'Maximum priority', type: 'number', lookup: 'lte' },
|
|
88
|
+
{ key: 'metadata__rule_id', label: 'Rule ID', type: 'text', lookup: 'exact' },
|
|
89
|
+
{ key: 'metadata__do_not_delete', label: 'Protected', type: 'boolean' },
|
|
90
|
+
];
|
|
91
|
+
return <ModelTable
|
|
92
|
+
model={IncidentModel} title="Incidents" eyebrow="Security operations"
|
|
93
|
+
columns={columns} filters={filters} searchable searchPlaceholder="Search incident details"
|
|
94
|
+
defaultParams={{ sort: '-id', status: 'new' }}
|
|
95
|
+
presets={[{ key: 'new', label: 'New', params: { status: 'new' } }, { key: 'open', label: 'Open', params: { status: 'open' } }, { key: 'investigating', label: 'Investigating', params: { status: 'investigating' } }, { key: 'all', label: 'All', params: {} }]}
|
|
96
|
+
rowTone={(row) => row.priority >= 8 ? 'danger' : row.priority >= 5 ? 'warning' : null}
|
|
97
|
+
exporter={INCIDENT_EXPORTER} exportFormats={['csv', 'json']}
|
|
98
|
+
columnChooser persistState persistKey="admin-security-incidents" autoRefresh={30}
|
|
99
|
+
onRowClick={(row) => showIncidentDetail(row.id)}
|
|
100
|
+
{...(canManage ? { selectable: true, batchActions: actions } : {})}
|
|
101
|
+
/>;
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Eyebrow, FlatRow, KnownFieldsCard, StackTraceView } from '../../ui';
|
|
3
|
+
import { boundedSecurityText, sanitizeSecurityValue } from './sanitize';
|
|
4
|
+
|
|
5
|
+
export function metadataOf(row: { metadata?: Record<string, unknown> }): Record<string, unknown> {
|
|
6
|
+
return sanitizeSecurityValue(row.metadata ?? {});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function first(metadata: Record<string, unknown>, ...keys: string[]): unknown {
|
|
10
|
+
for (const key of keys) if (metadata[key] != null && metadata[key] !== '') return metadata[key];
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function text(value: unknown): string { return value == null || value === '' ? '—' : boundedSecurityText(value, 8_000); }
|
|
15
|
+
|
|
16
|
+
export function RequestResponseForensics({ metadata }: { metadata: Record<string, unknown> }) {
|
|
17
|
+
const candidates: Array<[string, unknown]> = [
|
|
18
|
+
['Method', first(metadata, 'http_method', 'request_method', 'method')],
|
|
19
|
+
['Status', first(metadata, 'http_status', 'response_status', 'status_code')],
|
|
20
|
+
['Host', first(metadata, 'http_host', 'request_host', 'host')],
|
|
21
|
+
['Path / URL', first(metadata, 'http_url', 'http_path', 'request_path', 'url')],
|
|
22
|
+
['Protocol', first(metadata, 'http_protocol', 'protocol')],
|
|
23
|
+
['Query string', first(metadata, 'http_query_string', 'query_string')],
|
|
24
|
+
['User agent', first(metadata, 'http_user_agent', 'user_agent')],
|
|
25
|
+
['Request headers', first(metadata, 'request_headers', 'headers')],
|
|
26
|
+
['Request body', first(metadata, 'request_body', 'request_data', 'body')],
|
|
27
|
+
['Response headers', first(metadata, 'response_headers')],
|
|
28
|
+
['Response body', first(metadata, 'response_body', 'response_data')],
|
|
29
|
+
];
|
|
30
|
+
const fields = candidates.filter(([, value]) => value != null && value !== '');
|
|
31
|
+
if (!fields.length) return <p className="dim-italic">No request or response context was recorded.</p>;
|
|
32
|
+
return <div className="incident-forensic-list">{fields.map(([label, value]) => (
|
|
33
|
+
<FlatRow key={label} label={label}><pre className="incident-forensic-value">{text(value)}</pre></FlatRow>
|
|
34
|
+
))}</div>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function EvidenceCard({ metadata, extra }: { metadata: Record<string, unknown>; extra?: ReactNode }) {
|
|
38
|
+
return <>
|
|
39
|
+
<Eyebrow>Trigger and evidence</Eyebrow>
|
|
40
|
+
<KnownFieldsCard
|
|
41
|
+
data={metadata}
|
|
42
|
+
showRaw={false}
|
|
43
|
+
known={[
|
|
44
|
+
{ key: 'trigger', label: 'Trigger', hideEmpty: true },
|
|
45
|
+
{ key: 'action', label: 'Action', hideEmpty: true },
|
|
46
|
+
{ key: 'rule_id', label: 'Rule ID', hideEmpty: true },
|
|
47
|
+
{ key: 'risk_score', label: 'Risk score', hideEmpty: true },
|
|
48
|
+
{ key: 'decision', label: 'Decision', hideEmpty: true },
|
|
49
|
+
{ key: 'model_name', label: 'Model', hideEmpty: true },
|
|
50
|
+
{ key: 'model_id', label: 'Model ID', hideEmpty: true },
|
|
51
|
+
]}
|
|
52
|
+
/>
|
|
53
|
+
{extra}
|
|
54
|
+
</>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function TraceForensics({ metadata }: { metadata: Record<string, unknown> }) {
|
|
58
|
+
const trace = first(metadata, 'stack_trace', 'traceback');
|
|
59
|
+
return <StackTraceView trace={trace ? boundedSecurityText(trace) : ''} collapseAfter={24} />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const INCIDENT_METADATA_FIELDS = [
|
|
63
|
+
{ key: 'source_ip', label: 'Source IP', hideEmpty: true },
|
|
64
|
+
{ key: 'hostname', label: 'Hostname', hideEmpty: true },
|
|
65
|
+
{ key: 'country_code', label: 'Country', hideEmpty: true },
|
|
66
|
+
{ key: 'region', label: 'Region', hideEmpty: true },
|
|
67
|
+
{ key: 'city', label: 'City', hideEmpty: true },
|
|
68
|
+
{ key: 'rule_id', label: 'Rule ID', hideEmpty: true },
|
|
69
|
+
{ key: 'risk_score', label: 'Risk score', hideEmpty: true },
|
|
70
|
+
{ key: 'decision', label: 'Decision', hideEmpty: true },
|
|
71
|
+
{ key: 'muid', label: 'MUID', hideEmpty: true },
|
|
72
|
+
{ key: 'duid', label: 'DUID', hideEmpty: true },
|
|
73
|
+
] as const;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AdminRoute } from '../index';
|
|
2
|
+
import { SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
3
|
+
import { EventsPage } from './EventsPage';
|
|
4
|
+
import { IncidentsPage } from './IncidentsPage';
|
|
5
|
+
|
|
6
|
+
export * from './models';
|
|
7
|
+
export * from './sanitize';
|
|
8
|
+
export * from './IncidentsPage';
|
|
9
|
+
export * from './EventsPage';
|
|
10
|
+
export * from './IncidentDetail';
|
|
11
|
+
export * from './EventDetail';
|
|
12
|
+
|
|
13
|
+
export const INCIDENTS_ADMIN_ROUTES: AdminRoute[] = [
|
|
14
|
+
{ path: 'incidents', label: 'Incidents', component: IncidentsPage, permissions: SECURITY_VIEW_PERMS },
|
|
15
|
+
{ path: 'events', label: 'Events', component: EventsPage, permissions: SECURITY_VIEW_PERMS },
|
|
16
|
+
];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { defineModel, mojoCall, mojoList, type Params } from '../../client';
|
|
4
|
+
import { modal } from '../../ui';
|
|
5
|
+
import { SECURITY_DELETE_PERMS, SECURITY_MANAGE_PERMS, SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
6
|
+
import { sanitizeEventRow, sanitizeIncidentRow } from './sanitize';
|
|
7
|
+
|
|
8
|
+
export type Relation = number | { id: number; title?: string | null; name?: string | null } | null;
|
|
9
|
+
export interface IncidentRow {
|
|
10
|
+
id: number; created: number; priority: number; state: string; status: string;
|
|
11
|
+
scope: string; category: string; country_code: string | null; title: string | null;
|
|
12
|
+
details: string | null; model_name: string | null; model_id: number | null;
|
|
13
|
+
source_ip: string | null; hostname: string | null; metadata: Record<string, unknown>;
|
|
14
|
+
group_id: number | null; rule_set?: Relation; ip_info?: Record<string, unknown> | null;
|
|
15
|
+
}
|
|
16
|
+
export interface EventRow {
|
|
17
|
+
id: number; created: number; level: number; scope: string; category: string;
|
|
18
|
+
source_ip: string | null; hostname: string | null; uid: number | null;
|
|
19
|
+
country_code: string | null; title: string | null; details: string | null;
|
|
20
|
+
model_name: string | null; model_id: number | null; metadata: Record<string, unknown>;
|
|
21
|
+
group_id: number | null; incident?: Relation; geo_ip?: Record<string, unknown> | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const COMMON = new Set(['start', 'size', 'sort', 'search', 'dr_field', 'dr_start', 'dr_end']);
|
|
25
|
+
const INCIDENT_FILTERS = new Set([
|
|
26
|
+
'id', 'id__in', 'status', 'status__in', 'state', 'scope', 'scope__icontains', 'category',
|
|
27
|
+
'category__icontains', 'category__not', 'category__startswith', 'priority', 'priority__gte',
|
|
28
|
+
'priority__lte', 'priority__gt', 'priority__lt', 'metadata__rule_id',
|
|
29
|
+
'metadata__do_not_delete', 'created__gte', 'created__lte', 'source_ip', 'model_name', 'model_id',
|
|
30
|
+
]);
|
|
31
|
+
const EVENT_FILTERS = new Set([
|
|
32
|
+
'id', 'incident', 'category', 'category__icontains', 'category__not', 'category__not_in',
|
|
33
|
+
'category__startswith', 'scope', 'scope__icontains', 'source_ip', 'source_ip__icontains',
|
|
34
|
+
'level', 'level__gte', 'level__lte', 'level__gt', 'level__lt', 'created__gte', 'created__lte',
|
|
35
|
+
'model_name', 'model_name__icontains', 'model_id', 'metadata__server',
|
|
36
|
+
'metadata__server__icontains', 'metadata__http_url__icontains', 'metadata__http_path__icontains',
|
|
37
|
+
'metadata__http_query_string__icontains', 'metadata__http_status', 'metadata__http_user_agent__icontains',
|
|
38
|
+
'metadata__rule_id', 'metadata__country_code', 'metadata__region', 'metadata__city__icontains',
|
|
39
|
+
'metadata__user_email',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function normalize(params: Params, allowed: Set<string>, sort: string): Params {
|
|
43
|
+
const out: Params = { graph: 'default' };
|
|
44
|
+
for (const [key, value] of Object.entries(params)) {
|
|
45
|
+
if ((COMMON.has(key) || allowed.has(key)) && value != null && value !== '') out[key] = value;
|
|
46
|
+
}
|
|
47
|
+
out.sort = String(out.sort ?? sort);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export const normalizeIncidentListParams = (params: Params): Params => normalize(params, INCIDENT_FILTERS, '-id');
|
|
51
|
+
export const normalizeEventListParams = (params: Params): Params => {
|
|
52
|
+
const out = normalize(params, EVENT_FILTERS, '-created');
|
|
53
|
+
// Day grouping requires chronological contiguity.
|
|
54
|
+
if (out.sort !== 'created' && out.sort !== '-created') out.sort = '-created';
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const IncidentModel = defineModel<IncidentRow>({
|
|
59
|
+
name: 'incident', endpoint: '/api/incident/incident',
|
|
60
|
+
permissions: { view: SECURITY_VIEW_PERMS, manage: SECURITY_MANAGE_PERMS, delete: SECURITY_DELETE_PERMS },
|
|
61
|
+
actions: { merge: { permissions: SECURITY_MANAGE_PERMS, response: 'payload' } },
|
|
62
|
+
normalizeListParams: normalizeIncidentListParams, sanitizeRow: sanitizeIncidentRow,
|
|
63
|
+
});
|
|
64
|
+
export const EventModel = defineModel<EventRow>({
|
|
65
|
+
name: 'event', endpoint: '/api/incident/event',
|
|
66
|
+
permissions: { view: SECURITY_VIEW_PERMS, manage: SECURITY_MANAGE_PERMS },
|
|
67
|
+
normalizeListParams: normalizeEventListParams, sanitizeRow: sanitizeEventRow,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const INCIDENT_LIFECYCLE = ['pending', 'new', 'open', 'investigating', 'resolved', 'closed', 'ignored'] as const;
|
|
71
|
+
|
|
72
|
+
export function incidentDetailKey(id: number) { return [IncidentModel.endpoint, 'one', id, { graph: 'detailed' }] as const; }
|
|
73
|
+
export function eventDetailKey(id: number) { return [EventModel.endpoint, 'one', id, { graph: 'detailed' }] as const; }
|
|
74
|
+
export function useIncidentDetail(id: number | null) {
|
|
75
|
+
return useQuery({
|
|
76
|
+
queryKey: id == null ? [IncidentModel.endpoint, 'one', null, { graph: 'detailed' }] : incidentDetailKey(id),
|
|
77
|
+
queryFn: async () => sanitizeIncidentRow((await mojoCall(`${IncidentModel.endpoint}/${id!}`, { params: { graph: 'detailed' } })).data as IncidentRow),
|
|
78
|
+
enabled: id != null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function useEventDetail(id: number | null) {
|
|
82
|
+
return useQuery({
|
|
83
|
+
queryKey: id == null ? [EventModel.endpoint, 'one', null, { graph: 'detailed' }] : eventDetailKey(id),
|
|
84
|
+
queryFn: async () => sanitizeEventRow((await mojoCall(`${EventModel.endpoint}/${id!}`, { params: { graph: 'detailed' } })).data as EventRow),
|
|
85
|
+
enabled: id != null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export function useIncidentEvents(id: number | null) {
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: [EventModel.endpoint, { incident: id, graph: 'default', sort: '-created', size: 100 }],
|
|
91
|
+
queryFn: async () => {
|
|
92
|
+
const page = await mojoList<EventRow>(EventModel.endpoint, { incident: id!, graph: 'default', sort: '-created', size: 100 });
|
|
93
|
+
return { ...page, rows: page.rows.map(sanitizeEventRow) };
|
|
94
|
+
},
|
|
95
|
+
enabled: id != null,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildIncidentMerge(rows: readonly IncidentRow[], targetId: number): { target: IncidentRow; sourceIds: number[] } {
|
|
100
|
+
if (rows.length < 2) throw new Error('Select at least two incidents to merge.');
|
|
101
|
+
const target = rows.find((row) => row.id === targetId);
|
|
102
|
+
if (!target) throw new Error('Choose a target from the selected incidents.');
|
|
103
|
+
const sourceIds = [...new Set(rows.map((row) => row.id).filter((id) => id !== targetId))];
|
|
104
|
+
if (!sourceIds.length) throw new Error('A merge needs at least one source incident.');
|
|
105
|
+
return { target, sourceIds };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function showIncidentDetail(id: number): void {
|
|
109
|
+
void import('./IncidentDetail').then(({ IncidentDetail }) => modal.detail((close) => createElement(IncidentDetail, { id, onClose: () => close(null) })));
|
|
110
|
+
}
|
|
111
|
+
export function showEventDetail(id: number): void {
|
|
112
|
+
void import('./EventDetail').then(({ EventDetail }) => modal.detail((close) => createElement(EventDetail, { id, onClose: () => close(null) })));
|
|
113
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { safeFileReference } from '../../client/record-feed';
|
|
2
|
+
|
|
3
|
+
const REDACTED = '[redacted]';
|
|
4
|
+
const MAX_SECURITY_TEXT = 32_000;
|
|
5
|
+
const SENSITIVE_KEY = /^(?:authorization|proxy[-_]?authorization|cookie|set[-_]?cookie|password|passwd|passphrase|token|access[-_]?token|refresh[-_]?token|id[-_]?token|api[-_]?key|auth[-_]?key|client[-_]?secret|secret|credential|credentials|csrf|xsrf)(?:$|[-_])/i;
|
|
6
|
+
const SENSITIVE_PARAM = /([?&;](?:password|passwd|token|access_token|refresh_token|id_token|api_key|auth_key|client_secret|secret|csrf|xsrf)=)[^&#;\s]*/gi;
|
|
7
|
+
const HEADER_LINE = /^((?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token|x-csrf-token)\s*:\s*).+$/gim;
|
|
8
|
+
const AUTH_VALUE = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
9
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g;
|
|
10
|
+
const FORM_SECRET = /(^|[&;\s])((?:password|passwd|token|access_token|refresh_token|api_key|client_secret|secret|csrf|xsrf)=)[^&;\s]*/gi;
|
|
11
|
+
const LABELED_SECRET = /\b(token|password|passwd|api[ _-]?key|client[ _-]?secret|secret)(\s*(?:is|:|=)?\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
12
|
+
|
|
13
|
+
function sensitiveName(key: string): boolean {
|
|
14
|
+
return SENSITIVE_KEY.test(key.trim().toLowerCase().replace(/\s+/g, '_'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sanitizeSecurityText(value: string): string {
|
|
18
|
+
let text = value.replace(HEADER_LINE, `$1${REDACTED}`).replace(AUTH_VALUE, `$1 ${REDACTED}`).replace(JWT, REDACTED).replace(LABELED_SECRET, `$1$2${REDACTED}`);
|
|
19
|
+
text = text.replace(SENSITIVE_PARAM, `$1${REDACTED}`).replace(FORM_SECRET, `$1$2${REDACTED}`);
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
22
|
+
try { return JSON.stringify(sanitizeSecurityValue(JSON.parse(text))); } catch { /* conservative regex fallback above */ }
|
|
23
|
+
}
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function boundedSecurityText(value: unknown, limit = MAX_SECURITY_TEXT): string {
|
|
28
|
+
const raw = typeof value === 'string' ? value : value == null ? '' : (JSON.stringify(value, null, 2) ?? String(value));
|
|
29
|
+
const safe = sanitizeSecurityText(raw);
|
|
30
|
+
if (safe.length <= limit) return safe;
|
|
31
|
+
return `${safe.slice(0, limit)}\n… [truncated ${safe.length - limit} characters]`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeSecurityValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
|
35
|
+
if (typeof value === 'string') return sanitizeSecurityText(value) as T;
|
|
36
|
+
if (value == null || typeof value !== 'object') return value;
|
|
37
|
+
if (seen.has(value as object)) return '[circular]' as T;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
const out: unknown[] = [];
|
|
40
|
+
seen.set(value, out);
|
|
41
|
+
for (const item of value) out.push(sanitizeSecurityValue(item, seen));
|
|
42
|
+
return out as T;
|
|
43
|
+
}
|
|
44
|
+
const out: Record<string, unknown> = {};
|
|
45
|
+
seen.set(value as object, out);
|
|
46
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
47
|
+
out[key] = sensitiveName(key) ? REDACTED : sanitizeSecurityValue(item, seen);
|
|
48
|
+
}
|
|
49
|
+
return out as T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sanitizedRow<T extends Record<string, unknown>>(row: T): T {
|
|
53
|
+
// File relation objects are positively projected before recursive domain
|
|
54
|
+
// sanitization so browser/capability-bearing impostors never traverse it.
|
|
55
|
+
const rebuilt = 'media' in row ? { ...row, media: safeFileReference(row.media) } : row;
|
|
56
|
+
const safe = sanitizeSecurityValue(rebuilt) as Record<string, unknown>;
|
|
57
|
+
for (const key of ['details', 'stack_trace', 'traceback']) {
|
|
58
|
+
if (key in safe && safe[key] != null) safe[key] = boundedSecurityText(safe[key]);
|
|
59
|
+
}
|
|
60
|
+
if (safe.metadata && typeof safe.metadata === 'object') {
|
|
61
|
+
const metadata = safe.metadata as Record<string, unknown>;
|
|
62
|
+
for (const key of ['text', 'request_body', 'request_data', 'response_body', 'stack_trace', 'traceback']) {
|
|
63
|
+
if (key in metadata && metadata[key] != null) metadata[key] = boundedSecurityText(metadata[key]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return safe as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const sanitizeIncidentRow = <T,>(row: T): T => sanitizedRow(row as unknown as Record<string, unknown>) as T;
|
|
70
|
+
export const sanitizeEventRow = <T,>(row: T): T => sanitizedRow(row as unknown as Record<string, unknown>) as T;
|
|
71
|
+
export const sanitizeIncidentHistoryRow = <T,>(row: T): T => sanitizedRow(row as unknown as Record<string, unknown>) as T;
|