@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.
Files changed (182) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +43 -0
  2. package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
  3. package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
  4. package/Server/API/DashboardDomainAPI.ts +74 -0
  5. package/Server/API/MicrosoftTeamsAPI.ts +81 -180
  6. package/Server/API/StatusPageDomainAPI.ts +74 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Middleware/ProjectAuthorization.ts +6 -1
  11. package/Server/Services/DashboardDomainService.ts +131 -0
  12. package/Server/Services/LogPipelineProcessorService.ts +88 -0
  13. package/Server/Services/MonitorTemplateService.ts +70 -11
  14. package/Server/Services/StatusPageDomainService.ts +163 -3
  15. package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
  16. package/Server/Types/Domain.ts +17 -0
  17. package/Server/Utils/APIKey/AccessPermission.ts +24 -0
  18. package/Server/Utils/Greenlock/Greenlock.ts +156 -58
  19. package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
  20. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
  21. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
  22. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
  23. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
  24. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
  25. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
  26. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
  27. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
  28. package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
  29. package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
  30. package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
  31. package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
  32. package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
  33. package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
  34. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
  35. package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
  36. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
  37. package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
  38. package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
  39. package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
  40. package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
  41. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
  42. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
  43. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
  44. package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
  45. package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
  46. package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
  47. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
  48. package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
  49. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
  50. package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
  51. package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
  52. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
  53. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
  54. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
  55. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
  56. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
  57. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
  58. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
  59. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
  60. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
  61. package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
  62. package/Tests/Types/API/HTTPResponse.test.ts +175 -0
  63. package/Tests/Types/API/Route.test.ts +36 -4
  64. package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
  65. package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
  66. package/Tests/Types/DateLocalTimeString.test.ts +259 -0
  67. package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
  68. package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
  69. package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
  70. package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
  71. package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
  72. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
  73. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
  74. package/Tests/UI/Utils/Navigation.test.ts +82 -0
  75. package/Tests/Utils/Array.test.ts +100 -0
  76. package/Tests/Utils/CertificateReissue.test.ts +225 -0
  77. package/Tests/Utils/Grok/Grok.test.ts +492 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
  79. package/Types/API/Route.ts +20 -5
  80. package/Types/Date.ts +143 -13
  81. package/Types/Rum/SessionReplay.ts +62 -1
  82. package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
  83. package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
  84. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
  85. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
  86. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
  87. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
  88. package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
  89. package/UI/Utils/Navigation.ts +42 -0
  90. package/Utils/Array.ts +37 -0
  91. package/Utils/CertificateReissue.ts +128 -0
  92. package/Utils/Grok/Grok.ts +461 -0
  93. package/Utils/Grok/GrokPatterns.ts +118 -0
  94. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
  95. package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
  96. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
  98. package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
  100. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  101. package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
  102. package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
  103. package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
  104. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  105. package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
  106. package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  113. package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
  114. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  115. package/build/dist/Server/Services/DashboardDomainService.js +104 -0
  116. package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
  117. package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
  118. package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
  119. package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
  120. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  121. package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
  122. package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
  123. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
  124. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
  125. package/build/dist/Server/Types/Domain.js +16 -0
  126. package/build/dist/Server/Types/Domain.js.map +1 -1
  127. package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
  128. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  129. package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
  130. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  131. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
  132. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
  133. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
  134. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
  135. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
  136. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  137. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
  138. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  139. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
  140. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  141. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
  142. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
  143. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
  144. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  145. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
  146. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  147. package/build/dist/Types/API/Route.js +16 -5
  148. package/build/dist/Types/API/Route.js.map +1 -1
  149. package/build/dist/Types/Date.js +115 -13
  150. package/build/dist/Types/Date.js.map +1 -1
  151. package/build/dist/Types/Rum/SessionReplay.js +38 -0
  152. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  153. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
  154. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
  155. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
  156. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  157. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
  158. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
  159. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
  160. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  161. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
  162. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  163. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
  164. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  165. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
  166. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
  167. package/build/dist/UI/Utils/Navigation.js +30 -0
  168. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  169. package/build/dist/Utils/Array.js +25 -0
  170. package/build/dist/Utils/Array.js.map +1 -1
  171. package/build/dist/Utils/CertificateReissue.js +97 -0
  172. package/build/dist/Utils/CertificateReissue.js.map +1 -0
  173. package/build/dist/Utils/Grok/Grok.js +324 -0
  174. package/build/dist/Utils/Grok/Grok.js.map +1 -0
  175. package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
  176. package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
  178. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  179. package/package.json +1 -1
  180. package/UI/Utils/JsonWebToken.ts +0 -14
  181. package/build/dist/UI/Utils/JsonWebToken.js +0 -10
  182. 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
- const REGION_REGEX: RegExp = /^[a-z][a-z0-9-]{0,30}$/;
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 = data.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 (!REGION_REGEX.test(region || "")) {
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 = Math.floor(nowInMs / 1000);
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.fetchImplementation(
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, 500)}`,
364
+ `Google token exchange failed (HTTP ${response.status}): ${responseText.slice(0, BODY_ECHO_LIMIT)}`,
169
365
  );
170
366
  }
171
367
 
172
- const tokenResponse: JSONObject = JSON.parse(responseText) as JSONObject;
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 = Number(
175
- tokenResponse["expires_in"] || TOKEN_LIFETIME_IN_SECONDS,
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
- this.cachedAccessToken = accessToken;
183
- this.cachedAccessTokenExpiresAtInMs = nowInMs + expiresInSeconds * 1000;
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. Response shapes vary across tenant
191
- * versions, so parsing is deliberately tolerant: `alerts`, `detections`,
192
- * or a bare array all work; anything else returns [] rather than
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<Array<JSONObject>> {
200
- const accessToken: string = await this.getAccessToken();
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
- pageSize: String(data.maxAlerts || DEFAULT_MAX_ALERTS),
431
+ "alertListOptions.maxReturnedAlerts": String(maxReturnedAlerts),
206
432
  });
207
433
 
208
- const url: string = `${this.getApiBaseUrl()}/legacy:legacyFetchAlertsView?${params.toString()}`;
434
+ if (SNAPSHOT_QUERY) {
435
+ params.set("snapshotQuery", SNAPSHOT_QUERY);
436
+ }
209
437
 
210
- const response: FetchResponseLike = await this.fetchImplementation(url, {
211
- method: "GET",
212
- headers: {
213
- Authorization: `Bearer ${accessToken}`,
214
- },
215
- });
438
+ const url: string = `${this.getApiBaseUrl()}/legacy:legacyFetchAlertsView?${params.toString()}`;
216
439
 
217
- const responseText: string = await response.text();
440
+ let response: FetchResponseLike = await this.requestAlerts(
441
+ url,
442
+ accessToken,
443
+ );
444
+ let responseText: string = await response.text();
218
445
 
219
- if (!response.ok) {
220
- throw new APIException(
221
- `Google SecOps alerts fetch failed (HTTP ${response.status}): ${responseText.slice(0, 500)}`,
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
- let parsed: JSONValue;
226
-
227
- try {
228
- parsed = JSON.parse(responseText) as JSONValue;
229
- } catch {
460
+ if (!response.ok) {
230
461
  throw new APIException(
231
- "Google SecOps alerts fetch returned a non-JSON body.",
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.extractAlerts(parsed);
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
- return GoogleSecOpsClient.extractAlerts(nested);
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
  }