@oneuptime/common 12.0.28 → 12.0.29
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/Models/DatabaseModels/DashboardDomain.ts +43 -0
- package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
- package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
- package/Server/API/DashboardDomainAPI.ts +74 -0
- package/Server/API/MicrosoftTeamsAPI.ts +81 -180
- package/Server/API/StatusPageDomainAPI.ts +74 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/ProjectAuthorization.ts +6 -1
- package/Server/Services/DashboardDomainService.ts +131 -0
- package/Server/Services/LogPipelineProcessorService.ts +88 -0
- package/Server/Services/MonitorTemplateService.ts +70 -11
- package/Server/Services/StatusPageDomainService.ts +163 -3
- package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
- package/Server/Types/Domain.ts +17 -0
- package/Server/Utils/APIKey/AccessPermission.ts +24 -0
- package/Server/Utils/Greenlock/Greenlock.ts +156 -58
- package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
- package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
- package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
- package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
- package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
- package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
- package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
- package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
- package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
- package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
- package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
- package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
- package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
- package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
- package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
- package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
- package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
- package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
- package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
- package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
- package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
- package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
- package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
- package/Tests/Types/API/HTTPResponse.test.ts +175 -0
- package/Tests/Types/API/Route.test.ts +36 -4
- package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
- package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
- package/Tests/Types/DateLocalTimeString.test.ts +259 -0
- package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
- package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
- package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
- package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
- package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
- package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
- package/Tests/UI/Utils/Navigation.test.ts +82 -0
- package/Tests/Utils/Array.test.ts +100 -0
- package/Tests/Utils/CertificateReissue.test.ts +225 -0
- package/Tests/Utils/Grok/Grok.test.ts +492 -0
- package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
- package/Types/API/Route.ts +20 -5
- package/Types/Date.ts +143 -13
- package/Types/Rum/SessionReplay.ts +62 -1
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
- package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
- package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
- package/UI/Utils/Navigation.ts +42 -0
- package/Utils/Array.ts +37 -0
- package/Utils/CertificateReissue.ts +128 -0
- package/Utils/Grok/Grok.ts +461 -0
- package/Utils/Grok/GrokPatterns.ts +118 -0
- package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
- package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
- package/build/dist/Server/Services/DashboardDomainService.js +104 -0
- package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
- package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
- package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
- package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
- package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
- package/build/dist/Server/Types/Domain.js +16 -0
- package/build/dist/Server/Types/Domain.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
- package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/API/Route.js +16 -5
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/Date.js +115 -13
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplay.js +38 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +30 -0
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/Utils/Array.js +25 -0
- package/build/dist/Utils/Array.js.map +1 -1
- package/build/dist/Utils/CertificateReissue.js +97 -0
- package/build/dist/Utils/CertificateReissue.js.map +1 -0
- package/build/dist/Utils/Grok/Grok.js +324 -0
- package/build/dist/Utils/Grok/Grok.js.map +1 -0
- package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
- package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
- package/package.json +1 -1
- package/UI/Utils/JsonWebToken.ts +0 -14
- package/build/dist/UI/Utils/JsonWebToken.js +0 -10
- package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createPrivateKey } from "crypto";
|
|
1
2
|
import jwt from "jsonwebtoken";
|
|
2
3
|
import BadDataException from "../../../../Types/Exception/BadDataException";
|
|
3
4
|
import APIException from "../../../../Types/Exception/ApiException";
|
|
4
5
|
import { JSONArray, JSONObject, JSONValue } from "../../../../Types/JSON";
|
|
6
|
+
import logger from "../../Logger";
|
|
5
7
|
|
|
6
8
|
/*
|
|
7
9
|
* Minimal Google SecOps (Chronicle) API client for the detections poller.
|
|
@@ -13,6 +15,16 @@ import { JSONArray, JSONObject, JSONValue } from "../../../../Types/JSON";
|
|
|
13
15
|
*
|
|
14
16
|
* The HTTP layer is injectable so unit tests exercise the real request
|
|
15
17
|
* construction and response parsing against fixtures.
|
|
18
|
+
*
|
|
19
|
+
* legacyFetchAlertsView is a server-STREAMING method, so its 200 body is a
|
|
20
|
+
* JSON array of FetchAlertsViewResponse chunks and the alerts sit two
|
|
21
|
+
* levels down at chunk.alerts.alerts[]. Google's REST reference documents
|
|
22
|
+
* the streamed message, never the stream envelope, which is why an earlier
|
|
23
|
+
* version of this file looked for a top-level `alerts` array and therefore
|
|
24
|
+
* returned zero alerts against every well-formed response. The parser
|
|
25
|
+
* below refuses to report "no alerts" for a body it could not recognize —
|
|
26
|
+
* a shape it does not understand throws, because the poller treats an
|
|
27
|
+
* empty result as a healthy poll and advances its cursor past it.
|
|
16
28
|
*/
|
|
17
29
|
|
|
18
30
|
export interface GoogleServiceAccountCredentials {
|
|
@@ -27,24 +39,174 @@ export interface FetchResponseLike {
|
|
|
27
39
|
text: () => Promise<string>;
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
export interface FetchInitLike {
|
|
43
|
+
method: string;
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
body?: string | undefined;
|
|
46
|
+
signal?: AbortSignal | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
export type FetchLike = (
|
|
31
50
|
url: string,
|
|
32
|
-
init:
|
|
33
|
-
method: string;
|
|
34
|
-
headers: Record<string, string>;
|
|
35
|
-
body?: string | undefined;
|
|
36
|
-
},
|
|
51
|
+
init: FetchInitLike,
|
|
37
52
|
) => Promise<FetchResponseLike>;
|
|
38
53
|
|
|
54
|
+
/*
|
|
55
|
+
* What one alerts fetch learned, not just what it collected. The counts and
|
|
56
|
+
* flags are the only way a caller can tell "the window was quiet" from "the
|
|
57
|
+
* window was truncated" or "the stream ended early", all of which used to
|
|
58
|
+
* arrive as a bare empty array.
|
|
59
|
+
*/
|
|
60
|
+
export interface FetchAlertsResult {
|
|
61
|
+
alerts: Array<JSONObject>;
|
|
62
|
+
complete: boolean;
|
|
63
|
+
progress: number;
|
|
64
|
+
truncatedByCount: boolean;
|
|
65
|
+
truncatedByBytes: boolean;
|
|
66
|
+
baselineAlertsCount: number;
|
|
67
|
+
filteredAlertsCount: number;
|
|
68
|
+
chunkCount: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
const CHRONICLE_SCOPE: string =
|
|
40
72
|
"https://www.googleapis.com/auth/cloud-platform";
|
|
41
73
|
const TOKEN_LIFETIME_IN_SECONDS: number = 3600;
|
|
42
74
|
const TOKEN_EXPIRY_SLACK_IN_SECONDS: number = 60;
|
|
75
|
+
/*
|
|
76
|
+
* Google rejects an assertion whose iat is in the future, and a host clock
|
|
77
|
+
* a few seconds fast is enough to trigger it — reported back as
|
|
78
|
+
* invalid_grant, which reads exactly like a bad key. Backdate iat instead.
|
|
79
|
+
* exp stays iat + 3600 because 3600 is Google's hard maximum lifetime, so
|
|
80
|
+
* this shortens the token's usable life rather than extending it.
|
|
81
|
+
*/
|
|
82
|
+
const TOKEN_CLOCK_SKEW_IN_SECONDS: number = 60;
|
|
43
83
|
const DEFAULT_MAX_ALERTS: number = 1000;
|
|
44
84
|
|
|
45
|
-
|
|
85
|
+
/*
|
|
86
|
+
* Neither fetch had a deadline before, and Node's global fetch has none of
|
|
87
|
+
* its own. Connections are polled strictly sequentially, so one endpoint
|
|
88
|
+
* that accepts a connection and never answers stalls every other tenant's
|
|
89
|
+
* poll behind it, outliving the cron's own timeout because that timeout
|
|
90
|
+
* does not abort the in-flight socket.
|
|
91
|
+
*/
|
|
92
|
+
const REQUEST_TIMEOUT_IN_SECONDS: number = 60;
|
|
93
|
+
const REQUEST_TIMEOUT_IN_MS: number = REQUEST_TIMEOUT_IN_SECONDS * 1000;
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* How much of a body any diagnostic echoes. One bound for every echo site,
|
|
97
|
+
* because the integration doc and the connections page both quote a single
|
|
98
|
+
* figure — a second bound would make one of them wrong without anything
|
|
99
|
+
* saying so.
|
|
100
|
+
*/
|
|
101
|
+
const BODY_ECHO_LIMIT: number = 500;
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* The 22 documented {region}-chronicle.googleapis.com prefixes. An
|
|
105
|
+
* allowlist rather than a shape regex because *.googleapis.com is a DNS
|
|
106
|
+
* wildcard: a typo like "us-central1" resolves to a Google frontend and
|
|
107
|
+
* answers with an HTML 404, so a regex that merely looks safe turns a
|
|
108
|
+
* misconfigured region into a parse error instead of "unsupported region".
|
|
109
|
+
*/
|
|
110
|
+
const SUPPORTED_REGIONS: Array<string> = [
|
|
111
|
+
"us",
|
|
112
|
+
"eu",
|
|
113
|
+
"europe",
|
|
114
|
+
"africa-south1",
|
|
115
|
+
"asia-east1",
|
|
116
|
+
"asia-northeast1",
|
|
117
|
+
"asia-northeast3",
|
|
118
|
+
"asia-south1",
|
|
119
|
+
"asia-southeast1",
|
|
120
|
+
"asia-southeast2",
|
|
121
|
+
"australia-southeast1",
|
|
122
|
+
"europe-central2",
|
|
123
|
+
"europe-west12",
|
|
124
|
+
"europe-west2",
|
|
125
|
+
"europe-west3",
|
|
126
|
+
"europe-west6",
|
|
127
|
+
"europe-west9",
|
|
128
|
+
"me-central1",
|
|
129
|
+
"me-central2",
|
|
130
|
+
"me-west1",
|
|
131
|
+
"northamerica-northeast2",
|
|
132
|
+
"southamerica-east1",
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/*
|
|
136
|
+
* europe-chronicle.googleapis.com is a documented live host while the
|
|
137
|
+
* migration guide names the same multi-region's location code "eu", so the
|
|
138
|
+
* region/location cross-check has to treat the two as one place. A strict
|
|
139
|
+
* identity check would reject every valid EU tenant.
|
|
140
|
+
*/
|
|
141
|
+
const EU_REGION_ALIASES: Array<string> = ["eu", "europe"];
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The token endpoint is customer-supplied, and the first 500 characters of
|
|
145
|
+
* whatever answers it are echoed into lastError and rendered in the
|
|
146
|
+
* dashboard — a blind SSRF plus a read-back channel. Region and instance
|
|
147
|
+
* were always guarded; this one was not, which reads as an oversight
|
|
148
|
+
* rather than a decision.
|
|
149
|
+
*/
|
|
150
|
+
const TOKEN_URI_HOSTS: Array<string> = ["accounts.google.com"];
|
|
151
|
+
const TOKEN_URI_HOST_SUFFIX: string = ".googleapis.com";
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* `#` truncates the path and drops the whole query string; `?` injects
|
|
155
|
+
* parameters ahead of the real ones; `%` lets a segment smuggle an encoded
|
|
156
|
+
* separator past this check. The host is fixed by getApiBaseUrl, so this is
|
|
157
|
+
* request shaping rather than URL takeover — still config-controlled
|
|
158
|
+
* injection, and still rejected.
|
|
159
|
+
*/
|
|
46
160
|
const INSTANCE_REGEX: RegExp =
|
|
47
|
-
/^projects\/[^/\s]+\/locations\/[^/\s]+\/instances\/[^/\s]+$/;
|
|
161
|
+
/^projects\/[^/\s#?&%]+\/locations\/[^/\s#?&%]+\/instances\/[^/\s#?&%]+$/;
|
|
162
|
+
const INSTANCE_LOCATION_REGEX: RegExp =
|
|
163
|
+
/^projects\/[^/]+\/locations\/([^/]+)\//;
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* The two 400s worth telling apart in the operator message: a field we sent
|
|
167
|
+
* that does not exist, versus a field we omitted that is required. Both are
|
|
168
|
+
* OneUptime bugs, but they have different fixes.
|
|
169
|
+
*/
|
|
170
|
+
const UNKNOWN_FIELD_PATTERN: RegExp =
|
|
171
|
+
/cannot bind query parameter|unknown name/i;
|
|
172
|
+
const MISSING_FIELD_PATTERN: RegExp = /required|missing/i;
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
* The doc marks snapshotQuery `Required.`, but that is a field_behavior
|
|
176
|
+
* annotation the HTTP transcoder does not enforce; this service validates
|
|
177
|
+
* queries in-band (validSnapshotQuery / queryValidationErrors) rather than
|
|
178
|
+
* rejecting them. Fortinet's shipping connector omits it and gets 200, and
|
|
179
|
+
* the doc defines empty-snapshot-query semantics as "match all baseline".
|
|
180
|
+
* NOT verified against a live tenant.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately NOT Google's SDK default `feedback_summary.status != "CLOSED"`
|
|
183
|
+
* — that drops every CLOSED alert, trading a loud 400 for silent data loss.
|
|
184
|
+
* If it turns out to be enforced, the 400 will name the missing field and
|
|
185
|
+
* this is the one line to change.
|
|
186
|
+
*/
|
|
187
|
+
const SNAPSHOT_QUERY: string | null = null;
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* Every field a FetchAlertsViewResponse chunk may carry. A body in which no
|
|
191
|
+
* element carries at least one of these is not this endpoint's response,
|
|
192
|
+
* and must never be reported as "no alerts".
|
|
193
|
+
*/
|
|
194
|
+
const RECOGNIZED_CHUNK_FIELDS: Array<string> = [
|
|
195
|
+
"alerts",
|
|
196
|
+
"fieldAggregations",
|
|
197
|
+
"complete",
|
|
198
|
+
"progress",
|
|
199
|
+
"tooManyAlerts",
|
|
200
|
+
"memoryLimitExceeded",
|
|
201
|
+
"validBaselineQuery",
|
|
202
|
+
"validSnapshotQuery",
|
|
203
|
+
"baselineAlertsCount",
|
|
204
|
+
"filteredAlertsCount",
|
|
205
|
+
"queryValidationErrors",
|
|
206
|
+
"runtimeErrors",
|
|
207
|
+
// Retained so a tenant already served by the legacy top-level shape keeps working.
|
|
208
|
+
"detections",
|
|
209
|
+
];
|
|
48
210
|
|
|
49
211
|
export default class GoogleSecOpsClient {
|
|
50
212
|
private region: string;
|
|
@@ -63,9 +225,15 @@ export default class GoogleSecOpsClient {
|
|
|
63
225
|
}) {
|
|
64
226
|
GoogleSecOpsClient.validateRegion(data.region);
|
|
65
227
|
GoogleSecOpsClient.validateInstanceResourceName(data.instanceResourceName);
|
|
228
|
+
GoogleSecOpsClient.validateRegionMatchesInstance(
|
|
229
|
+
data.region,
|
|
230
|
+
data.instanceResourceName,
|
|
231
|
+
);
|
|
66
232
|
|
|
67
233
|
this.region = data.region;
|
|
68
|
-
this.instanceResourceName =
|
|
234
|
+
this.instanceResourceName = GoogleSecOpsClient.encodeInstanceResourceName(
|
|
235
|
+
data.instanceResourceName,
|
|
236
|
+
);
|
|
69
237
|
this.credentials = GoogleSecOpsClient.parseServiceAccountJson(
|
|
70
238
|
data.serviceAccountJson,
|
|
71
239
|
);
|
|
@@ -74,7 +242,7 @@ export default class GoogleSecOpsClient {
|
|
|
74
242
|
}
|
|
75
243
|
|
|
76
244
|
public static validateRegion(region: string): void {
|
|
77
|
-
if (
|
|
245
|
+
if (SUPPORTED_REGIONS.indexOf(region || "") === -1) {
|
|
78
246
|
throw new BadDataException(
|
|
79
247
|
"Region must be a Google SecOps regional prefix like 'us' or 'europe'.",
|
|
80
248
|
);
|
|
@@ -100,6 +268,29 @@ export default class GoogleSecOpsClient {
|
|
|
100
268
|
throw new BadDataException("Service account JSON is not valid JSON.");
|
|
101
269
|
}
|
|
102
270
|
|
|
271
|
+
/*
|
|
272
|
+
* JSON.parse("null") and JSON.parse("[]") both succeed, and the reads
|
|
273
|
+
* below would then throw a raw TypeError out of a public create/update
|
|
274
|
+
* API — a 500 where the caller's input deserves a 400.
|
|
275
|
+
*/
|
|
276
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
277
|
+
throw new BadDataException("Service account JSON must be a JSON object.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/*
|
|
281
|
+
* Read before coercing: String({}) is "[object Object]", which is
|
|
282
|
+
* non-empty and sails through every check here only to fail an hour
|
|
283
|
+
* later inside jwt.sign, on the cron, where nobody is looking.
|
|
284
|
+
*/
|
|
285
|
+
if (
|
|
286
|
+
typeof parsed["client_email"] !== "string" ||
|
|
287
|
+
typeof parsed["private_key"] !== "string"
|
|
288
|
+
) {
|
|
289
|
+
throw new BadDataException(
|
|
290
|
+
"Service account JSON must contain client_email and private_key as strings.",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
103
294
|
const clientEmail: string = String(parsed["client_email"] || "");
|
|
104
295
|
const privateKey: string = String(parsed["private_key"] || "");
|
|
105
296
|
const tokenUri: string = String(
|
|
@@ -112,6 +303,9 @@ export default class GoogleSecOpsClient {
|
|
|
112
303
|
);
|
|
113
304
|
}
|
|
114
305
|
|
|
306
|
+
GoogleSecOpsClient.validateTokenUri(tokenUri);
|
|
307
|
+
GoogleSecOpsClient.validatePrivateKey(privateKey);
|
|
308
|
+
|
|
115
309
|
return { clientEmail, privateKey, tokenUri };
|
|
116
310
|
}
|
|
117
311
|
|
|
@@ -131,7 +325,8 @@ export default class GoogleSecOpsClient {
|
|
|
131
325
|
return this.cachedAccessToken;
|
|
132
326
|
}
|
|
133
327
|
|
|
134
|
-
const issuedAtInSeconds: number =
|
|
328
|
+
const issuedAtInSeconds: number =
|
|
329
|
+
Math.floor(nowInMs / 1000) - TOKEN_CLOCK_SKEW_IN_SECONDS;
|
|
135
330
|
|
|
136
331
|
const assertion: string = jwt.sign(
|
|
137
332
|
{
|
|
@@ -150,7 +345,7 @@ export default class GoogleSecOpsClient {
|
|
|
150
345
|
assertion: assertion,
|
|
151
346
|
}).toString();
|
|
152
347
|
|
|
153
|
-
const response: FetchResponseLike = await this.
|
|
348
|
+
const response: FetchResponseLike = await this.fetchWithTimeout(
|
|
154
349
|
this.credentials.tokenUri,
|
|
155
350
|
{
|
|
156
351
|
method: "POST",
|
|
@@ -159,82 +354,126 @@ export default class GoogleSecOpsClient {
|
|
|
159
354
|
},
|
|
160
355
|
body: body,
|
|
161
356
|
},
|
|
357
|
+
"token exchange",
|
|
162
358
|
);
|
|
163
359
|
|
|
164
360
|
const responseText: string = await response.text();
|
|
165
361
|
|
|
166
362
|
if (!response.ok) {
|
|
167
363
|
throw new APIException(
|
|
168
|
-
`Google token exchange failed (HTTP ${response.status}): ${responseText.slice(0,
|
|
364
|
+
`Google token exchange failed (HTTP ${response.status}): ${responseText.slice(0, BODY_ECHO_LIMIT)}`,
|
|
169
365
|
);
|
|
170
366
|
}
|
|
171
367
|
|
|
172
|
-
|
|
368
|
+
/*
|
|
369
|
+
* Symmetrical with the alerts parse below: a proxy answering 200 with
|
|
370
|
+
* an HTML page would otherwise throw a raw SyntaxError, which is
|
|
371
|
+
* neither APIException nor BadDataException and so escapes the
|
|
372
|
+
* failure taxonomy the operator guidance is written against.
|
|
373
|
+
*/
|
|
374
|
+
const tokenResponse: JSONObject =
|
|
375
|
+
GoogleSecOpsClient.parseTokenResponse(responseText);
|
|
173
376
|
const accessToken: string = String(tokenResponse["access_token"] || "");
|
|
174
|
-
const expiresInSeconds: number =
|
|
175
|
-
tokenResponse["expires_in"]
|
|
377
|
+
const expiresInSeconds: number = GoogleSecOpsClient.readExpiresInSeconds(
|
|
378
|
+
tokenResponse["expires_in"],
|
|
176
379
|
);
|
|
177
380
|
|
|
178
381
|
if (!accessToken) {
|
|
179
382
|
throw new APIException("Google token exchange returned no access_token.");
|
|
180
383
|
}
|
|
181
384
|
|
|
182
|
-
|
|
183
|
-
|
|
385
|
+
/*
|
|
386
|
+
* A zero, negative or unparseable expires_in used to become either an
|
|
387
|
+
* hour of caching a dead token or a NaN that made the cache test
|
|
388
|
+
* permanently false. Neither is worth guessing at: use the token once
|
|
389
|
+
* and mint a fresh one next time.
|
|
390
|
+
*/
|
|
391
|
+
if (expiresInSeconds > 0) {
|
|
392
|
+
this.cachedAccessToken = accessToken;
|
|
393
|
+
this.cachedAccessTokenExpiresAtInMs = nowInMs + expiresInSeconds * 1000;
|
|
394
|
+
} else {
|
|
395
|
+
this.cachedAccessToken = null;
|
|
396
|
+
this.cachedAccessTokenExpiresAtInMs = 0;
|
|
397
|
+
}
|
|
184
398
|
|
|
185
399
|
return accessToken;
|
|
186
400
|
}
|
|
187
401
|
|
|
188
402
|
/*
|
|
189
403
|
* Fetch detection alerts created in a time window, via the Chronicle
|
|
190
|
-
* v1alpha legacy alerts view.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* guessing.
|
|
404
|
+
* v1alpha legacy alerts view. The endpoint has no pagination of any
|
|
405
|
+
* kind, so maxReturnedAlerts is a real ceiling rather than a page size —
|
|
406
|
+
* truncation is reported through the result's flags, never swallowed.
|
|
194
407
|
*/
|
|
195
408
|
public async fetchDetectionAlerts(data: {
|
|
196
409
|
startTime: Date;
|
|
197
410
|
endTime: Date;
|
|
198
411
|
maxAlerts?: number | undefined;
|
|
199
|
-
}): Promise<
|
|
200
|
-
|
|
412
|
+
}): Promise<FetchAlertsResult> {
|
|
413
|
+
let accessToken: string = await this.getAccessToken();
|
|
201
414
|
|
|
415
|
+
const maxReturnedAlerts: number = data.maxAlerts || DEFAULT_MAX_ALERTS;
|
|
416
|
+
|
|
417
|
+
/*
|
|
418
|
+
* `alertListOptions.maxReturnedAlerts` is the flattened field path the
|
|
419
|
+
* HTTP transcoder binds AlertListOptions.max_returned_alerts from. No
|
|
420
|
+
* Google page prints this literal for this method — it is derived from
|
|
421
|
+
* google.api.HttpRule transcoding and corroborated by two independent
|
|
422
|
+
* shipping clients (Google's own secops-wrapper SDK and Fortinet's
|
|
423
|
+
* certified FortiSOAR connector), not verified verbatim in the docs.
|
|
424
|
+
* It is safe to send anyway because the parameter is optional and the
|
|
425
|
+
* failure mode is loud: a wrong name 400s with `Unknown name`, exactly
|
|
426
|
+
* like the `pageSize` this replaced, and never fails silently.
|
|
427
|
+
*/
|
|
202
428
|
const params: URLSearchParams = new URLSearchParams({
|
|
203
429
|
"timeRange.startTime": data.startTime.toISOString(),
|
|
204
430
|
"timeRange.endTime": data.endTime.toISOString(),
|
|
205
|
-
|
|
431
|
+
"alertListOptions.maxReturnedAlerts": String(maxReturnedAlerts),
|
|
206
432
|
});
|
|
207
433
|
|
|
208
|
-
|
|
434
|
+
if (SNAPSHOT_QUERY) {
|
|
435
|
+
params.set("snapshotQuery", SNAPSHOT_QUERY);
|
|
436
|
+
}
|
|
209
437
|
|
|
210
|
-
const
|
|
211
|
-
method: "GET",
|
|
212
|
-
headers: {
|
|
213
|
-
Authorization: `Bearer ${accessToken}`,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
438
|
+
const url: string = `${this.getApiBaseUrl()}/legacy:legacyFetchAlertsView?${params.toString()}`;
|
|
216
439
|
|
|
217
|
-
|
|
440
|
+
let response: FetchResponseLike = await this.requestAlerts(
|
|
441
|
+
url,
|
|
442
|
+
accessToken,
|
|
443
|
+
);
|
|
444
|
+
let responseText: string = await response.text();
|
|
218
445
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
446
|
+
/*
|
|
447
|
+
* A key revoked mid-lifetime leaves a cached token Google now refuses,
|
|
448
|
+
* and every poll until its stated expiry fails against it. One retry
|
|
449
|
+
* on a fresh token distinguishes "the token went stale" from "the
|
|
450
|
+
* credential is genuinely rejected", which is what the operator needs
|
|
451
|
+
* to read off lastError.
|
|
452
|
+
*/
|
|
453
|
+
if (response.status === 401) {
|
|
454
|
+
this.clearCachedAccessToken();
|
|
455
|
+
accessToken = await this.getAccessToken();
|
|
456
|
+
response = await this.requestAlerts(url, accessToken);
|
|
457
|
+
responseText = await response.text();
|
|
223
458
|
}
|
|
224
459
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
parsed = JSON.parse(responseText) as JSONValue;
|
|
229
|
-
} catch {
|
|
460
|
+
if (!response.ok) {
|
|
230
461
|
throw new APIException(
|
|
231
|
-
|
|
462
|
+
`Google SecOps alerts fetch failed (HTTP ${response.status}): ${responseText.slice(0, BODY_ECHO_LIMIT)}` +
|
|
463
|
+
GoogleSecOpsClient.describeHttpFailure(response.status, responseText),
|
|
232
464
|
);
|
|
233
465
|
}
|
|
234
466
|
|
|
235
|
-
return GoogleSecOpsClient.
|
|
467
|
+
return GoogleSecOpsClient.parseAlertsBody(responseText, maxReturnedAlerts);
|
|
236
468
|
}
|
|
237
469
|
|
|
470
|
+
/*
|
|
471
|
+
* The legacy top-level shapes, kept as a fallback so a tenant already
|
|
472
|
+
* being served by them keeps working. The recognition gate in
|
|
473
|
+
* parseAlertsBody runs ahead of this, so returning [] here means "this
|
|
474
|
+
* recognized chunk carried nothing", never "I did not understand the
|
|
475
|
+
* body".
|
|
476
|
+
*/
|
|
238
477
|
public static extractAlerts(payload: JSONValue): Array<JSONObject> {
|
|
239
478
|
if (Array.isArray(payload)) {
|
|
240
479
|
return (payload as JSONArray).filter((item: JSONValue): boolean => {
|
|
@@ -249,14 +488,824 @@ export default class GoogleSecOpsClient {
|
|
|
249
488
|
}
|
|
250
489
|
|
|
251
490
|
const asObject: JSONObject = payload as JSONObject;
|
|
491
|
+
const collected: Array<JSONObject> = [];
|
|
252
492
|
|
|
493
|
+
/*
|
|
494
|
+
* Every array-valued key, not the first one found: `{alerts: [],
|
|
495
|
+
* detections: [...]}` used to return [] and drop the detections,
|
|
496
|
+
* because an empty array is still an array.
|
|
497
|
+
*/
|
|
253
498
|
for (const key of ["alerts", "detections"]) {
|
|
254
499
|
const nested: JSONValue = asObject[key] as JSONValue;
|
|
500
|
+
|
|
255
501
|
if (Array.isArray(nested)) {
|
|
256
|
-
|
|
502
|
+
for (const item of GoogleSecOpsClient.extractAlerts(nested)) {
|
|
503
|
+
collected.push(item);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return collected;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/*
|
|
512
|
+
* ---------------------------------------------------------------------
|
|
513
|
+
* Helpers. Everything below is deliberately declared after extractAlerts:
|
|
514
|
+
* SecurityEventsConnectorGuidanceAccuracy reads this file's method
|
|
515
|
+
* bodies by slicing between declarations, so a helper placed higher up
|
|
516
|
+
* would be counted as part of fetchDetectionAlerts.
|
|
517
|
+
* ---------------------------------------------------------------------
|
|
518
|
+
*/
|
|
519
|
+
|
|
520
|
+
private clearCachedAccessToken(): void {
|
|
521
|
+
this.cachedAccessToken = null;
|
|
522
|
+
this.cachedAccessTokenExpiresAtInMs = 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async requestAlerts(
|
|
526
|
+
url: string,
|
|
527
|
+
accessToken: string,
|
|
528
|
+
): Promise<FetchResponseLike> {
|
|
529
|
+
/*
|
|
530
|
+
* Accept is not a formality here: leaving content negotiation to the
|
|
531
|
+
* server default is what turns a proxy in the path into the
|
|
532
|
+
* "non-JSON body" failure below.
|
|
533
|
+
*/
|
|
534
|
+
return this.fetchWithTimeout(
|
|
535
|
+
url,
|
|
536
|
+
{
|
|
537
|
+
method: "GET",
|
|
538
|
+
headers: {
|
|
539
|
+
Authorization: `Bearer ${accessToken}`,
|
|
540
|
+
Accept: "application/json",
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
"alerts fetch",
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private async fetchWithTimeout(
|
|
548
|
+
url: string,
|
|
549
|
+
init: FetchInitLike,
|
|
550
|
+
stepLabel: string,
|
|
551
|
+
): Promise<FetchResponseLike> {
|
|
552
|
+
const controller: AbortController = new AbortController();
|
|
553
|
+
const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
|
|
554
|
+
controller.abort();
|
|
555
|
+
}, REQUEST_TIMEOUT_IN_MS);
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
return await this.fetchImplementation(url, {
|
|
559
|
+
...init,
|
|
560
|
+
signal: controller.signal,
|
|
561
|
+
});
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (controller.signal.aborted) {
|
|
564
|
+
throw new APIException(
|
|
565
|
+
`Google SecOps ${stepLabel} timed out after ${REQUEST_TIMEOUT_IN_SECONDS} seconds with no response.`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
throw error;
|
|
570
|
+
} finally {
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private static validateTokenUri(tokenUri: string): void {
|
|
576
|
+
let parsed: URL;
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
parsed = new URL(tokenUri);
|
|
580
|
+
} catch {
|
|
581
|
+
throw new BadDataException(
|
|
582
|
+
"Service account token_uri must be an absolute https URL.",
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const isGoogleHost: boolean =
|
|
587
|
+
TOKEN_URI_HOSTS.indexOf(parsed.hostname) !== -1 ||
|
|
588
|
+
parsed.hostname.endsWith(TOKEN_URI_HOST_SUFFIX);
|
|
589
|
+
|
|
590
|
+
if (parsed.protocol !== "https:" || !isGoogleHost || parsed.username) {
|
|
591
|
+
throw new BadDataException(
|
|
592
|
+
"Service account token_uri must be an https URL on a Google host such as https://oauth2.googleapis.com/token.",
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private static validatePrivateKey(privateKey: string): void {
|
|
598
|
+
/*
|
|
599
|
+
* A double-escaped or truncated key saves cleanly today and fails an
|
|
600
|
+
* hour later inside jwt.sign as an unwrapped OpenSSL decoder error, on
|
|
601
|
+
* the cron — which is the exact class of failure save-time validation
|
|
602
|
+
* exists to prevent.
|
|
603
|
+
*/
|
|
604
|
+
try {
|
|
605
|
+
createPrivateKey(privateKey);
|
|
606
|
+
} catch {
|
|
607
|
+
throw new BadDataException(
|
|
608
|
+
"Service account private_key is not a readable PEM private key. Check that newlines are real newlines and the key is not encrypted.",
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private static validateRegionMatchesInstance(
|
|
614
|
+
region: string,
|
|
615
|
+
instanceResourceName: string,
|
|
616
|
+
): void {
|
|
617
|
+
const match: RegExpMatchArray | null = instanceResourceName.match(
|
|
618
|
+
INSTANCE_LOCATION_REGEX,
|
|
619
|
+
);
|
|
620
|
+
const location: string = match && match[1] ? match[1] : "";
|
|
621
|
+
|
|
622
|
+
if (!location || location === region) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (
|
|
627
|
+
EU_REGION_ALIASES.indexOf(region) !== -1 &&
|
|
628
|
+
EU_REGION_ALIASES.indexOf(location) !== -1
|
|
629
|
+
) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
throw new BadDataException(
|
|
634
|
+
"Region must match the locations segment of the instance resource name.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private static encodeInstanceResourceName(name: string): string {
|
|
639
|
+
return name
|
|
640
|
+
.split("/")
|
|
641
|
+
.map((segment: string): string => {
|
|
642
|
+
return encodeURIComponent(segment);
|
|
643
|
+
})
|
|
644
|
+
.join("/");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private static parseTokenResponse(responseText: string): JSONObject {
|
|
648
|
+
let parsed: JSONValue;
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
parsed = JSON.parse(responseText) as JSONValue;
|
|
652
|
+
} catch {
|
|
653
|
+
throw new APIException("Google token exchange returned a non-JSON body.");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!GoogleSecOpsClient.isJsonObject(parsed)) {
|
|
657
|
+
throw new APIException(
|
|
658
|
+
"Google token exchange returned a body that is not a JSON object.",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return parsed;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private static readExpiresInSeconds(value: JSONValue | undefined): number {
|
|
666
|
+
if (value === undefined || value === null || value === "") {
|
|
667
|
+
return TOKEN_LIFETIME_IN_SECONDS;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const seconds: number = Number(value);
|
|
671
|
+
|
|
672
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
673
|
+
logger.warn(
|
|
674
|
+
`GoogleSecOpsClient: token endpoint returned an unusable expires_in (${String(value)}); not caching this access token.`,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return seconds;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/*
|
|
684
|
+
* The streaming envelope. Returns what the window actually contained, or
|
|
685
|
+
* throws — the one thing it must never do is report zero alerts for a
|
|
686
|
+
* body it did not recognize, because the poller reads that as a healthy
|
|
687
|
+
* quiet window and advances its cursor past whatever it failed to read.
|
|
688
|
+
*/
|
|
689
|
+
public static parseAlertsBody(
|
|
690
|
+
bodyText: string,
|
|
691
|
+
maxReturnedAlerts: number = DEFAULT_MAX_ALERTS,
|
|
692
|
+
): FetchAlertsResult {
|
|
693
|
+
const text: string = (bodyText || "").trim();
|
|
694
|
+
|
|
695
|
+
if (!text) {
|
|
696
|
+
throw new APIException(
|
|
697
|
+
"Google SecOps alerts fetch returned an empty body.",
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const root: JSONValue = GoogleSecOpsClient.parseAlertsJson(text);
|
|
702
|
+
const chunks: Array<JSONObject> = GoogleSecOpsClient.toChunkArray(
|
|
703
|
+
root,
|
|
704
|
+
text,
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
/*
|
|
708
|
+
* Before anything is accumulated. A terminal google.rpc.Status can
|
|
709
|
+
* legitimately follow good data — [{alerts},{alerts},{error}] is a
|
|
710
|
+
* mid-stream failure — so a scan that stopped at the first element, or
|
|
711
|
+
* trusted the alerts it had already seen, would mask it.
|
|
712
|
+
*/
|
|
713
|
+
GoogleSecOpsClient.throwOnStreamError(chunks);
|
|
714
|
+
GoogleSecOpsClient.throwOnUnrecognizedChunks(chunks, text);
|
|
715
|
+
|
|
716
|
+
const alerts: Array<JSONObject> =
|
|
717
|
+
GoogleSecOpsClient.accumulateAlerts(chunks);
|
|
718
|
+
|
|
719
|
+
const complete: boolean =
|
|
720
|
+
GoogleSecOpsClient.readLastBoolean(chunks, "complete") === true;
|
|
721
|
+
const progress: number =
|
|
722
|
+
GoogleSecOpsClient.readLastNumber(chunks, "progress") ?? 0;
|
|
723
|
+
const truncatedByCount: boolean =
|
|
724
|
+
GoogleSecOpsClient.readLastBoolean(chunks, "tooManyAlerts") === true;
|
|
725
|
+
const truncatedByBytes: boolean =
|
|
726
|
+
GoogleSecOpsClient.readLastBoolean(chunks, "memoryLimitExceeded") ===
|
|
727
|
+
true;
|
|
728
|
+
const baselineAlertsCount: number =
|
|
729
|
+
GoogleSecOpsClient.readLastNumber(chunks, "baselineAlertsCount") ?? 0;
|
|
730
|
+
const filteredAlertsCount: number =
|
|
731
|
+
GoogleSecOpsClient.readLastNumber(chunks, "filteredAlertsCount") ?? 0;
|
|
732
|
+
|
|
733
|
+
GoogleSecOpsClient.throwOnInBandValidationErrors(chunks);
|
|
734
|
+
|
|
735
|
+
if (truncatedByCount) {
|
|
736
|
+
logger.warn(
|
|
737
|
+
"GoogleSecOpsClient: Chronicle set tooManyAlerts — the window matched more alerts than it will return, and this endpoint has no pagination. Narrow the poll window.",
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (truncatedByBytes) {
|
|
742
|
+
logger.warn(
|
|
743
|
+
"GoogleSecOpsClient: Chronicle set memoryLimitExceeded — the result was truncated server side. Narrow the poll window.",
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!complete) {
|
|
748
|
+
/*
|
|
749
|
+
* Warn rather than re-issue the whole GET in a loop the way Google's
|
|
750
|
+
* SDK does: the poller runs every minute over a 15-minute window
|
|
751
|
+
* with a minute of overlap, so a partial window is re-covered on the
|
|
752
|
+
* next tick, and a blocking retry inside a strictly sequential
|
|
753
|
+
* connection loop would let one slow tenant starve every other one.
|
|
754
|
+
*/
|
|
755
|
+
logger.warn(
|
|
756
|
+
"GoogleSecOpsClient: the alerts stream ended without complete=true; this window may be partial and will be re-covered by the next poll.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (alerts.length > maxReturnedAlerts) {
|
|
761
|
+
/*
|
|
762
|
+
* Whether chunk.alerts is cumulative or incremental across chunks is
|
|
763
|
+
* the one part of this contract no Google page states. The union
|
|
764
|
+
* below is correct either way, but more alerts than the ceiling we
|
|
765
|
+
* asked for can only mean we appended across chunks that were
|
|
766
|
+
* restating the same top-N — an observable answer to an otherwise
|
|
767
|
+
* unanswerable doc question.
|
|
768
|
+
*/
|
|
769
|
+
logger.error(
|
|
770
|
+
`GoogleSecOpsClient: accumulated ${alerts.length} alerts for a ceiling of ${maxReturnedAlerts}. Chunk alerts are cumulative and the union is over-counting; the dedupe key is not identifying them.`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
alerts: alerts,
|
|
776
|
+
complete: complete,
|
|
777
|
+
progress: progress,
|
|
778
|
+
truncatedByCount: truncatedByCount,
|
|
779
|
+
truncatedByBytes: truncatedByBytes,
|
|
780
|
+
baselineAlertsCount: baselineAlertsCount,
|
|
781
|
+
filteredAlertsCount: filteredAlertsCount,
|
|
782
|
+
chunkCount: chunks.length,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private static parseAlertsJson(text: string): JSONValue {
|
|
787
|
+
try {
|
|
788
|
+
return JSON.parse(text) as JSONValue;
|
|
789
|
+
} catch {
|
|
790
|
+
// Fall through to the repair pass.
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
return JSON.parse(GoogleSecOpsClient.repairJsonBody(text)) as JSONValue;
|
|
795
|
+
} catch {
|
|
796
|
+
throw new APIException(
|
|
797
|
+
"Google SecOps alerts fetch returned a non-JSON body.",
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/*
|
|
803
|
+
* Bounded, and only after a strict parse has already failed. Google's own
|
|
804
|
+
* SDK ships an equivalent fixer, which only makes sense because malformed
|
|
805
|
+
* bodies were observed in the wild; it does not license repairing
|
|
806
|
+
* arbitrary text, so nothing here invents structure that was not there.
|
|
807
|
+
*/
|
|
808
|
+
private static repairJsonBody(text: string): string {
|
|
809
|
+
let repaired: string = text.replace(/\}\s*\n\s*\{/g, "},\n{");
|
|
810
|
+
|
|
811
|
+
repaired = repaired.replace(/,\s*([\]}])/g, "$1");
|
|
812
|
+
|
|
813
|
+
if (repaired.startsWith("{") && repaired.endsWith("}")) {
|
|
814
|
+
repaired = `[${repaired}]`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return repaired;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private static toChunkArray(
|
|
821
|
+
root: JSONValue,
|
|
822
|
+
text: string,
|
|
823
|
+
): Array<JSONObject> {
|
|
824
|
+
if (Array.isArray(root)) {
|
|
825
|
+
return (root as JSONArray).filter((item: JSONValue): boolean => {
|
|
826
|
+
return GoogleSecOpsClient.isJsonObject(item);
|
|
827
|
+
}) as Array<JSONObject>;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// A unary body instead of the stream envelope is tolerated.
|
|
831
|
+
if (GoogleSecOpsClient.isJsonObject(root)) {
|
|
832
|
+
return [root];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
throw new APIException(
|
|
836
|
+
`Google SecOps alerts fetch returned an unexpected response root: ${text.slice(0, BODY_ECHO_LIMIT)}`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private static throwOnStreamError(chunks: Array<JSONObject>): void {
|
|
841
|
+
for (const chunk of chunks) {
|
|
842
|
+
const error: JSONValue | undefined = chunk["error"];
|
|
843
|
+
|
|
844
|
+
if (GoogleSecOpsClient.isJsonObject(error)) {
|
|
845
|
+
throw new APIException(
|
|
846
|
+
`Google SecOps alerts fetch returned an error in the response stream: ${GoogleSecOpsClient.summarizeErrorObject(error)}`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private static throwOnUnrecognizedChunks(
|
|
853
|
+
chunks: Array<JSONObject>,
|
|
854
|
+
text: string,
|
|
855
|
+
): void {
|
|
856
|
+
const recognized: Array<JSONObject> = chunks.filter(
|
|
857
|
+
(chunk: JSONObject): boolean => {
|
|
858
|
+
return RECOGNIZED_CHUNK_FIELDS.some((field: string): boolean => {
|
|
859
|
+
return Object.prototype.hasOwnProperty.call(chunk, field);
|
|
860
|
+
});
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
if (recognized.length === 0) {
|
|
865
|
+
throw new APIException(
|
|
866
|
+
`Google SecOps alerts fetch returned an unrecognized response shape: ${text.slice(0, BODY_ECHO_LIMIT)}`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/*
|
|
872
|
+
* Dedupe-by-id union, which is correct whether chunk.alerts is cumulative
|
|
873
|
+
* (later chunks refresh earlier entries) or incremental (entries
|
|
874
|
+
* accumulate). A blind push would double-count under the first reading; a
|
|
875
|
+
* blind replace would lose data under the second.
|
|
876
|
+
*/
|
|
877
|
+
private static accumulateAlerts(
|
|
878
|
+
chunks: Array<JSONObject>,
|
|
879
|
+
): Array<JSONObject> {
|
|
880
|
+
const seen: Map<string, JSONObject> = new Map<string, JSONObject>();
|
|
881
|
+
|
|
882
|
+
for (let chunkIndex: number = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
883
|
+
const chunk: JSONObject = chunks[chunkIndex] as JSONObject;
|
|
884
|
+
const chunkAlerts: Array<JSONObject> =
|
|
885
|
+
GoogleSecOpsClient.alertsInChunk(chunk);
|
|
886
|
+
|
|
887
|
+
for (
|
|
888
|
+
let position: number = 0;
|
|
889
|
+
position < chunkAlerts.length;
|
|
890
|
+
position++
|
|
891
|
+
) {
|
|
892
|
+
const alert: JSONObject = chunkAlerts[position] as JSONObject;
|
|
893
|
+
const id: JSONValue | undefined = alert["id"];
|
|
894
|
+
const key: string =
|
|
895
|
+
typeof id === "string" && id ? id : `__idx:${chunkIndex}:${position}`;
|
|
896
|
+
|
|
897
|
+
seen.set(key, alert);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return Array.from(seen.values());
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private static alertsInChunk(chunk: JSONObject): Array<JSONObject> {
|
|
905
|
+
const collected: Array<JSONObject> = [];
|
|
906
|
+
const alertList: JSONValue | undefined = chunk["alerts"];
|
|
907
|
+
|
|
908
|
+
/*
|
|
909
|
+
* FetchAlertsViewResponse.alerts is an AlertList message whose single
|
|
910
|
+
* field is also called `alerts`. Both levels are omitted when empty.
|
|
911
|
+
*/
|
|
912
|
+
if (GoogleSecOpsClient.isJsonObject(alertList)) {
|
|
913
|
+
const inner: JSONValue | undefined = alertList["alerts"];
|
|
914
|
+
|
|
915
|
+
if (Array.isArray(inner)) {
|
|
916
|
+
for (const item of GoogleSecOpsClient.extractAlerts(
|
|
917
|
+
inner as JSONValue,
|
|
918
|
+
)) {
|
|
919
|
+
collected.push(item);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
for (const item of GoogleSecOpsClient.extractAlerts(chunk)) {
|
|
925
|
+
collected.push(item);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return collected;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/*
|
|
932
|
+
* complete, progress and the counts are proto3 scalars: a chunk where
|
|
933
|
+
* complete is false and progress is 0 omits both keys entirely, so
|
|
934
|
+
* "absent" must never be read as "present and false" — hence last chunk
|
|
935
|
+
* that CARRIES the key, not last chunk.
|
|
936
|
+
*/
|
|
937
|
+
private static readLastBoolean(
|
|
938
|
+
chunks: Array<JSONObject>,
|
|
939
|
+
field: string,
|
|
940
|
+
): boolean | null {
|
|
941
|
+
let value: boolean | null = null;
|
|
942
|
+
|
|
943
|
+
for (const chunk of chunks) {
|
|
944
|
+
if (typeof chunk[field] === "boolean") {
|
|
945
|
+
value = chunk[field] as boolean;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return value;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private static readLastNumber(
|
|
953
|
+
chunks: Array<JSONObject>,
|
|
954
|
+
field: string,
|
|
955
|
+
): number | null {
|
|
956
|
+
let value: number | null = null;
|
|
957
|
+
|
|
958
|
+
for (const chunk of chunks) {
|
|
959
|
+
const read: number | null = GoogleSecOpsClient.readNumber(chunk[field]);
|
|
960
|
+
|
|
961
|
+
if (read !== null) {
|
|
962
|
+
value = read;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return value;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private static readNumber(value: JSONValue | undefined): number | null {
|
|
970
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
971
|
+
return value;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// proto3 renders int64 as a JSON string, so counts can arrive quoted.
|
|
975
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
976
|
+
const parsed: number = Number(value);
|
|
977
|
+
|
|
978
|
+
if (Number.isFinite(parsed)) {
|
|
979
|
+
return parsed;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/*
|
|
987
|
+
* A malformed query comes back as HTTP 200 with the complaint in the
|
|
988
|
+
* body, so checking response.ok alone reports it as a successful poll
|
|
989
|
+
* that found nothing.
|
|
990
|
+
*/
|
|
991
|
+
private static throwOnInBandValidationErrors(
|
|
992
|
+
chunks: Array<JSONObject>,
|
|
993
|
+
): void {
|
|
994
|
+
const queryErrors: Array<string> = GoogleSecOpsClient.collectErrorTexts(
|
|
995
|
+
chunks,
|
|
996
|
+
"queryValidationErrors",
|
|
997
|
+
);
|
|
998
|
+
const runtimeErrors: Array<string> = GoogleSecOpsClient.collectErrorTexts(
|
|
999
|
+
chunks,
|
|
1000
|
+
"runtimeErrors",
|
|
1001
|
+
);
|
|
1002
|
+
const invalidSnapshotQuery: boolean =
|
|
1003
|
+
GoogleSecOpsClient.readLastBoolean(chunks, "validSnapshotQuery") ===
|
|
1004
|
+
false;
|
|
1005
|
+
const invalidBaselineQuery: boolean =
|
|
1006
|
+
GoogleSecOpsClient.readLastBoolean(chunks, "validBaselineQuery") ===
|
|
1007
|
+
false;
|
|
1008
|
+
|
|
1009
|
+
if (
|
|
1010
|
+
!invalidSnapshotQuery &&
|
|
1011
|
+
!invalidBaselineQuery &&
|
|
1012
|
+
queryErrors.length === 0 &&
|
|
1013
|
+
runtimeErrors.length === 0
|
|
1014
|
+
) {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const reasons: Array<string> = [];
|
|
1019
|
+
|
|
1020
|
+
if (invalidSnapshotQuery) {
|
|
1021
|
+
reasons.push("validSnapshotQuery=false");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (invalidBaselineQuery) {
|
|
1025
|
+
reasons.push("validBaselineQuery=false");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
for (const text of queryErrors) {
|
|
1029
|
+
reasons.push(text);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
for (const text of runtimeErrors) {
|
|
1033
|
+
reasons.push(text);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
throw new APIException(
|
|
1037
|
+
`Google SecOps alerts query was rejected by Chronicle on an HTTP 200: ${reasons.join("; ").slice(0, BODY_ECHO_LIMIT)}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private static collectErrorTexts(
|
|
1042
|
+
chunks: Array<JSONObject>,
|
|
1043
|
+
field: string,
|
|
1044
|
+
): Array<string> {
|
|
1045
|
+
/*
|
|
1046
|
+
* Deduped rather than concatenated, for the same reason the alerts are:
|
|
1047
|
+
* a cumulative chunk restates the whole list every time.
|
|
1048
|
+
*/
|
|
1049
|
+
const seen: Map<string, string> = new Map<string, string>();
|
|
1050
|
+
|
|
1051
|
+
for (const chunk of chunks) {
|
|
1052
|
+
const entries: JSONValue | undefined = chunk[field];
|
|
1053
|
+
|
|
1054
|
+
if (!Array.isArray(entries)) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
for (const entry of entries as Array<JSONValue>) {
|
|
1059
|
+
const text: string = GoogleSecOpsClient.errorTextOf(entry);
|
|
1060
|
+
|
|
1061
|
+
if (text) {
|
|
1062
|
+
seen.set(text, text);
|
|
1063
|
+
}
|
|
257
1064
|
}
|
|
258
1065
|
}
|
|
259
1066
|
|
|
260
|
-
return
|
|
1067
|
+
return Array.from(seen.values());
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private static errorTextOf(entry: JSONValue): string {
|
|
1071
|
+
if (typeof entry === "string") {
|
|
1072
|
+
return entry;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (GoogleSecOpsClient.isJsonObject(entry)) {
|
|
1076
|
+
/*
|
|
1077
|
+
* The reference page does not print ValidationError's field names, so
|
|
1078
|
+
* the candidates below are a best effort; the JSON fallback keeps the
|
|
1079
|
+
* operator-visible text honest when none of them match.
|
|
1080
|
+
*/
|
|
1081
|
+
for (const key of ["errorText", "message", "error", "description"]) {
|
|
1082
|
+
const value: JSONValue | undefined = entry[key];
|
|
1083
|
+
|
|
1084
|
+
if (typeof value === "string" && value) {
|
|
1085
|
+
return value;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return JSON.stringify(entry);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return String(entry);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/*
|
|
1096
|
+
* Actionable operator guidance appended behind the echoed body. The
|
|
1097
|
+
* prefix, the status and the body slice ahead of it are the contract the
|
|
1098
|
+
* integration doc and the in-product help are written against, so this
|
|
1099
|
+
* only ever adds to the tail.
|
|
1100
|
+
*/
|
|
1101
|
+
public static describeHttpFailure(status: number, bodyText: string): string {
|
|
1102
|
+
const error: JSONObject | null =
|
|
1103
|
+
GoogleSecOpsClient.findErrorObject(bodyText);
|
|
1104
|
+
const reason: string = GoogleSecOpsClient.errorInfoReason(error);
|
|
1105
|
+
const message: string = error
|
|
1106
|
+
? String(error["message"] || "")
|
|
1107
|
+
: bodyText.slice(0, BODY_ECHO_LIMIT);
|
|
1108
|
+
|
|
1109
|
+
const hint: string = reason
|
|
1110
|
+
? GoogleSecOpsClient.hintForReason(reason, error)
|
|
1111
|
+
: GoogleSecOpsClient.hintForStatus(status, message, error);
|
|
1112
|
+
|
|
1113
|
+
if (!hint) {
|
|
1114
|
+
return "";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return ` — ${hint}`;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private static hintForReason(
|
|
1121
|
+
reason: string,
|
|
1122
|
+
error: JSONObject | null,
|
|
1123
|
+
): string {
|
|
1124
|
+
if (reason === "CREDENTIALS_MISSING") {
|
|
1125
|
+
return "No credential reached Google. The Authorization header was absent or unreadable.";
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (reason === "ACCESS_TOKEN_EXPIRED") {
|
|
1129
|
+
return "The access token had expired; OneUptime mints a fresh one and retries once, so a repeat means the clock or the key is wrong.";
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT") {
|
|
1133
|
+
return "The token was minted without the Chronicle scope. Grant the service account https://www.googleapis.com/auth/cloud-platform or .../auth/chronicle.";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (reason === "IAM_PERMISSION_DENIED") {
|
|
1137
|
+
// ErrorInfo.metadata names the exact resource and permission; render both.
|
|
1138
|
+
const metadata: JSONObject | null =
|
|
1139
|
+
GoogleSecOpsClient.errorInfoMetadata(error);
|
|
1140
|
+
const resource: string = String(metadata?.["resource"] || "");
|
|
1141
|
+
const permission: string = String(metadata?.["permission"] || "");
|
|
1142
|
+
|
|
1143
|
+
return `The service account lacks ${permission || "chronicle.legacies.legacyFetchAlertsView"} on ${resource || "the instance"}. Grant roles/chronicle.viewer (roles/chronicle.admin if Viewer is not enough on this tenant).`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (reason === "SERVICE_DISABLED") {
|
|
1147
|
+
return "The Chronicle API is not enabled on the project the instance is bound to. Enable it in the Google Cloud console.";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (reason === "RATE_LIMIT_EXCEEDED") {
|
|
1151
|
+
const metadata: JSONObject | null =
|
|
1152
|
+
GoogleSecOpsClient.errorInfoMetadata(error);
|
|
1153
|
+
const quotaMetric: string = String(metadata?.["quota_metric"] || "");
|
|
1154
|
+
const quotaLimit: string = String(metadata?.["quota_limit"] || "");
|
|
1155
|
+
|
|
1156
|
+
return `Google is rate limiting this service account${quotaMetric ? ` on ${quotaMetric}` : ""}${quotaLimit ? ` (limit ${quotaLimit})` : ""}. Chronicle quota is per user per hour, so one service account shared across connections collides with itself.`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return `Google reported ${reason}.`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private static hintForStatus(
|
|
1163
|
+
status: number,
|
|
1164
|
+
message: string,
|
|
1165
|
+
error: JSONObject | null,
|
|
1166
|
+
): string {
|
|
1167
|
+
if (status === 400) {
|
|
1168
|
+
/*
|
|
1169
|
+
* AIP-193 requires a service-generated error to carry ErrorInfo, so a
|
|
1170
|
+
* 400 that carries only BadRequest came from the HTTP transcoder —
|
|
1171
|
+
* which means OneUptime's request shape is wrong, never the
|
|
1172
|
+
* customer's credentials.
|
|
1173
|
+
*/
|
|
1174
|
+
if (UNKNOWN_FIELD_PATTERN.test(message)) {
|
|
1175
|
+
return "OneUptime sent a query parameter this endpoint does not accept. This is a OneUptime bug, not a credential or permission problem.";
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (MISSING_FIELD_PATTERN.test(message)) {
|
|
1179
|
+
return "Chronicle rejected the request for a missing required field. This is a OneUptime bug, not a credential or permission problem.";
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (!error) {
|
|
1183
|
+
return "Chronicle rejected the request shape before it reached the service. This is a OneUptime bug, not a credential or permission problem.";
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return "";
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (status === 401) {
|
|
1190
|
+
return "Google refused the access token. The service-account key may be revoked, or this host's clock may be skewed.";
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (status === 403) {
|
|
1194
|
+
return "Either the instance resource name is wrong, or the service account lacks access to it. Grant roles/chronicle.viewer (roles/chronicle.admin if Viewer is not enough on this tenant) — permission is checked before existence, so a wrong instance name usually reads as 403 rather than 404.";
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (status === 404) {
|
|
1198
|
+
return "The route did not resolve. Check the region prefix: googleapis.com is a DNS wildcard, so a mistyped region answers with a generic 404 rather than an API error.";
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (status === 429) {
|
|
1202
|
+
return "RESOURCE_EXHAUSTED. Back off before the next poll; Chronicle quota is per user per hour, so one service account shared across connections collides with itself.";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return "";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/*
|
|
1209
|
+
* us-chronicle.googleapis.com wraps its errors in the stream's array
|
|
1210
|
+
* envelope, while an error rejected at the edge — bad auth, unknown route
|
|
1211
|
+
* — comes back bare. Both shapes are read, and every element is scanned:
|
|
1212
|
+
* the terminal google.rpc.Status is appended last, so body[0] is exactly
|
|
1213
|
+
* the wrong place to look.
|
|
1214
|
+
*/
|
|
1215
|
+
private static findErrorObject(bodyText: string): JSONObject | null {
|
|
1216
|
+
const text: string = (bodyText || "").trim();
|
|
1217
|
+
|
|
1218
|
+
if (!text) {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
let root: JSONValue;
|
|
1223
|
+
|
|
1224
|
+
try {
|
|
1225
|
+
root = JSON.parse(text) as JSONValue;
|
|
1226
|
+
} catch {
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const elements: Array<JSONValue> = Array.isArray(root)
|
|
1231
|
+
? (root as Array<JSONValue>)
|
|
1232
|
+
: [root];
|
|
1233
|
+
|
|
1234
|
+
for (const element of elements) {
|
|
1235
|
+
if (!GoogleSecOpsClient.isJsonObject(element)) {
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const error: JSONValue | undefined = element["error"];
|
|
1240
|
+
|
|
1241
|
+
if (GoogleSecOpsClient.isJsonObject(error)) {
|
|
1242
|
+
return error;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private static errorInfoDetail(error: JSONObject | null): JSONObject | null {
|
|
1250
|
+
if (!error) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const details: JSONValue | undefined = error["details"];
|
|
1255
|
+
|
|
1256
|
+
if (!Array.isArray(details)) {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
for (const detail of details as Array<JSONValue>) {
|
|
1261
|
+
if (!GoogleSecOpsClient.isJsonObject(detail)) {
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (
|
|
1266
|
+
String(detail["@type"] || "").endsWith("google.rpc.ErrorInfo") &&
|
|
1267
|
+
detail["reason"]
|
|
1268
|
+
) {
|
|
1269
|
+
return detail;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private static errorInfoReason(error: JSONObject | null): string {
|
|
1277
|
+
const detail: JSONObject | null = GoogleSecOpsClient.errorInfoDetail(error);
|
|
1278
|
+
|
|
1279
|
+
return detail ? String(detail["reason"] || "") : "";
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private static errorInfoMetadata(
|
|
1283
|
+
error: JSONObject | null,
|
|
1284
|
+
): JSONObject | null {
|
|
1285
|
+
const detail: JSONObject | null = GoogleSecOpsClient.errorInfoDetail(error);
|
|
1286
|
+
|
|
1287
|
+
if (!detail) {
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const metadata: JSONValue | undefined = detail["metadata"];
|
|
1292
|
+
|
|
1293
|
+
return GoogleSecOpsClient.isJsonObject(metadata) ? metadata : null;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private static summarizeErrorObject(error: JSONObject): string {
|
|
1297
|
+
const code: string = String(error["code"] || "");
|
|
1298
|
+
const status: string = String(error["status"] || "");
|
|
1299
|
+
const message: string = String(error["message"] || "");
|
|
1300
|
+
|
|
1301
|
+
return `${code ? `code ${code} ` : ""}${status ? `${status} ` : ""}${message}`
|
|
1302
|
+
.trim()
|
|
1303
|
+
.slice(0, BODY_ECHO_LIMIT);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
private static isJsonObject(
|
|
1307
|
+
value: JSONValue | undefined,
|
|
1308
|
+
): value is JSONObject {
|
|
1309
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
261
1310
|
}
|
|
262
1311
|
}
|