@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
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "crypto";
|
|
2
|
+
import GoogleSecOpsClient, {
|
|
3
|
+
FetchAlertsResult,
|
|
4
|
+
FetchInitLike,
|
|
5
|
+
FetchLike,
|
|
6
|
+
FetchResponseLike,
|
|
7
|
+
GoogleServiceAccountCredentials,
|
|
8
|
+
} from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient";
|
|
9
|
+
import BadDataException from "../../../../Types/Exception/BadDataException";
|
|
10
|
+
import { describe, expect, test } from "@jest/globals";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* The wire contract of the alerts request, asserted by parsing.
|
|
14
|
+
*
|
|
15
|
+
* This connector shipped broken for one reason: the only request coverage
|
|
16
|
+
* that existed was `toContain` on the path prefix and on the encoded ISO
|
|
17
|
+
* timestamp, and both stay green under ANY parameter rename. A query
|
|
18
|
+
* string carrying `pageSize` — a parameter this endpoint does not have —
|
|
19
|
+
* passed every test run and 400'd at Google's transcoder in production.
|
|
20
|
+
* So nothing here matches a substring of a URL: every parameter assertion
|
|
21
|
+
* goes through `new URL(...).searchParams` and pins the SORTED KEY LIST,
|
|
22
|
+
* which fails on an extra, a renamed, or a missing key alike.
|
|
23
|
+
*
|
|
24
|
+
* The second half pins the validation standing between a customer-supplied
|
|
25
|
+
* connection row and an outbound request — the region allowlist, the
|
|
26
|
+
* region/location cross-check, the token_uri host pin, the service-account
|
|
27
|
+
* parse and the instance-name shape. Each of those used to admit something
|
|
28
|
+
* that either could not work or should never have been reachable.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* Google's real OAuth endpoint. It is also the routing key for the
|
|
33
|
+
* injected transport below: the token exchange and the Chronicle call are
|
|
34
|
+
* told apart by host, never by call order, so a test asserting on "the
|
|
35
|
+
* alerts request" cannot accidentally be asserting on the token POST.
|
|
36
|
+
*/
|
|
37
|
+
const GOOGLE_TOKEN_URI: string = "https://oauth2.googleapis.com/token";
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* A real RS256 key, because the client genuinely signs a JWT assertion
|
|
41
|
+
* before it ever reaches Chronicle; a fake key fails inside jwt.sign and
|
|
42
|
+
* the request under test is never built.
|
|
43
|
+
*/
|
|
44
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
45
|
+
modulusLength: 2048,
|
|
46
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
47
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const SERVICE_ACCOUNT_JSON: string = JSON.stringify({
|
|
51
|
+
client_email: "poller@example.iam.gserviceaccount.com",
|
|
52
|
+
private_key: privateKey,
|
|
53
|
+
token_uri: GOOGLE_TOKEN_URI,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const INSTANCE: string =
|
|
57
|
+
"projects/my-project/locations/us/instances/3f0a-instance";
|
|
58
|
+
|
|
59
|
+
const ACCESS_TOKEN: string = "ya29.test-access-token";
|
|
60
|
+
|
|
61
|
+
const WINDOW_START: Date = new Date("2026-08-21T09:00:00.000Z");
|
|
62
|
+
const WINDOW_END: Date = new Date("2026-08-21T10:00:00.000Z");
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* The whole parameter set, sorted. Kept as one constant so every request
|
|
66
|
+
* test asserts the same closed list rather than each one checking the
|
|
67
|
+
* subset it happens to care about.
|
|
68
|
+
*/
|
|
69
|
+
const EXPECTED_QUERY_KEYS: Array<string> = [
|
|
70
|
+
"alertListOptions.maxReturnedAlerts",
|
|
71
|
+
"timeRange.endTime",
|
|
72
|
+
"timeRange.startTime",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* The 22 documented {region}-chronicle.googleapis.com prefixes. Written
|
|
77
|
+
* out rather than imported because the point is to pin the set against the
|
|
78
|
+
* documentation, not against whatever the module currently holds.
|
|
79
|
+
*/
|
|
80
|
+
const DOCUMENTED_REGION_PREFIXES: Array<string> = [
|
|
81
|
+
"us",
|
|
82
|
+
"eu",
|
|
83
|
+
"europe",
|
|
84
|
+
"africa-south1",
|
|
85
|
+
"asia-east1",
|
|
86
|
+
"asia-northeast1",
|
|
87
|
+
"asia-northeast3",
|
|
88
|
+
"asia-south1",
|
|
89
|
+
"asia-southeast1",
|
|
90
|
+
"asia-southeast2",
|
|
91
|
+
"australia-southeast1",
|
|
92
|
+
"europe-central2",
|
|
93
|
+
"europe-west12",
|
|
94
|
+
"europe-west2",
|
|
95
|
+
"europe-west3",
|
|
96
|
+
"europe-west6",
|
|
97
|
+
"europe-west9",
|
|
98
|
+
"me-central1",
|
|
99
|
+
"me-central2",
|
|
100
|
+
"me-west1",
|
|
101
|
+
"northamerica-northeast2",
|
|
102
|
+
"southamerica-east1",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
interface StubbedResponse {
|
|
106
|
+
status: number;
|
|
107
|
+
body: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RecordedRequest {
|
|
111
|
+
url: string;
|
|
112
|
+
method: string;
|
|
113
|
+
headers: Record<string, string>;
|
|
114
|
+
body: string | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function okTokenResponse(): StubbedResponse {
|
|
118
|
+
return {
|
|
119
|
+
status: 200,
|
|
120
|
+
body: JSON.stringify({ access_token: ACCESS_TOKEN, expires_in: 3600 }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* The canonical streaming envelope: an array of one chunk with the alerts
|
|
126
|
+
* two levels down at alerts.alerts[]. complete is set so no test on this
|
|
127
|
+
* path trips the partial-window warning and turns into log noise.
|
|
128
|
+
*/
|
|
129
|
+
function okAlertsResponse(): StubbedResponse {
|
|
130
|
+
return {
|
|
131
|
+
status: 200,
|
|
132
|
+
body: JSON.stringify([
|
|
133
|
+
{
|
|
134
|
+
alerts: { alerts: [{ id: "alert-1" }] },
|
|
135
|
+
complete: true,
|
|
136
|
+
progress: 1,
|
|
137
|
+
},
|
|
138
|
+
]),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function makeFetch(responses: {
|
|
143
|
+
token: StubbedResponse;
|
|
144
|
+
alerts: StubbedResponse;
|
|
145
|
+
}): {
|
|
146
|
+
fetchImplementation: FetchLike;
|
|
147
|
+
requests: Array<RecordedRequest>;
|
|
148
|
+
} {
|
|
149
|
+
const requests: Array<RecordedRequest> = [];
|
|
150
|
+
|
|
151
|
+
const fetchImplementation: FetchLike = (
|
|
152
|
+
url: string,
|
|
153
|
+
init: FetchInitLike,
|
|
154
|
+
): Promise<FetchResponseLike> => {
|
|
155
|
+
requests.push({
|
|
156
|
+
url: url,
|
|
157
|
+
method: init.method,
|
|
158
|
+
headers: init.headers,
|
|
159
|
+
body: init.body,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const response: StubbedResponse =
|
|
163
|
+
url === GOOGLE_TOKEN_URI ? responses.token : responses.alerts;
|
|
164
|
+
|
|
165
|
+
return Promise.resolve({
|
|
166
|
+
ok: response.status >= 200 && response.status < 300,
|
|
167
|
+
status: response.status,
|
|
168
|
+
text: (): Promise<string> => {
|
|
169
|
+
return Promise.resolve(response.body);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { fetchImplementation: fetchImplementation, requests: requests };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function makeClient(overrides?: {
|
|
178
|
+
region?: string | undefined;
|
|
179
|
+
instanceResourceName?: string | undefined;
|
|
180
|
+
}): { client: GoogleSecOpsClient; requests: Array<RecordedRequest> } {
|
|
181
|
+
const { fetchImplementation, requests } = makeFetch({
|
|
182
|
+
token: okTokenResponse(),
|
|
183
|
+
alerts: okAlertsResponse(),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const client: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
187
|
+
region: overrides?.region || "us",
|
|
188
|
+
instanceResourceName: overrides?.instanceResourceName || INSTANCE,
|
|
189
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
190
|
+
fetchImplementation: fetchImplementation,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { client: client, requests: requests };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/*
|
|
197
|
+
* For the tests that only construct a client. A transport that throws on
|
|
198
|
+
* use proves construction really is offline, so a validation assertion can
|
|
199
|
+
* never be satisfied by a round trip nobody noticed.
|
|
200
|
+
*/
|
|
201
|
+
function neverCalledFetch(): FetchLike {
|
|
202
|
+
return (): Promise<FetchResponseLike> => {
|
|
203
|
+
throw new Error(
|
|
204
|
+
"This test constructs a client only; no HTTP request was expected.",
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* The Chronicle request, isolated from the token exchange by host. Throws
|
|
211
|
+
* rather than returning the wrong one, so a routing mistake in the harness
|
|
212
|
+
* can never be read as a passing request-shape assertion.
|
|
213
|
+
*/
|
|
214
|
+
function alertsRequestIn(requests: Array<RecordedRequest>): RecordedRequest {
|
|
215
|
+
const chronicleRequests: Array<RecordedRequest> = requests.filter(
|
|
216
|
+
(request: RecordedRequest): boolean => {
|
|
217
|
+
return request.url !== GOOGLE_TOKEN_URI;
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (chronicleRequests.length !== 1) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Expected exactly one Chronicle request, recorded ${chronicleRequests.length}.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return chronicleRequests[0]!;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function queryParamsOf(request: RecordedRequest): URLSearchParams {
|
|
231
|
+
return new URL(request.url).searchParams;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sortedQueryKeysOf(request: RecordedRequest): Array<string> {
|
|
235
|
+
const keys: Array<string> = [];
|
|
236
|
+
|
|
237
|
+
queryParamsOf(request).forEach((_value: string, key: string): void => {
|
|
238
|
+
keys.push(key);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return keys.sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/*
|
|
245
|
+
* The query string as it actually goes on the wire, before URLSearchParams
|
|
246
|
+
* decodes it. Encoding is load-bearing in both directions: the transcoder
|
|
247
|
+
* binds on the literal field path, so the `.` must survive as a `.`, while
|
|
248
|
+
* the `:` in an RFC 3339 timestamp must not survive as a `:`.
|
|
249
|
+
*/
|
|
250
|
+
function rawQueryPairsOf(request: RecordedRequest): Map<string, string> {
|
|
251
|
+
const search: string = new URL(request.url).search;
|
|
252
|
+
const query: string = search.startsWith("?") ? search.slice(1) : search;
|
|
253
|
+
const pairs: Map<string, string> = new Map<string, string>();
|
|
254
|
+
|
|
255
|
+
if (!query) {
|
|
256
|
+
return pairs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const pair of query.split("&")) {
|
|
260
|
+
const separatorIndex: number = pair.indexOf("=");
|
|
261
|
+
|
|
262
|
+
pairs.set(
|
|
263
|
+
separatorIndex === -1 ? pair : pair.slice(0, separatorIndex),
|
|
264
|
+
separatorIndex === -1 ? "" : pair.slice(separatorIndex + 1),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return pairs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sortedKeysOf(pairs: Map<string, string>): Array<string> {
|
|
272
|
+
const keys: Array<string> = [];
|
|
273
|
+
|
|
274
|
+
pairs.forEach((_value: string, key: string): void => {
|
|
275
|
+
keys.push(key);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return keys.sort();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function errorThrownBy(run: () => unknown): unknown {
|
|
282
|
+
try {
|
|
283
|
+
run();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return error;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
throw new Error("Expected this call to throw, and it returned instead.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function messageThrownBy(run: () => unknown): string {
|
|
292
|
+
return (errorThrownBy(run) as Error).message;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function serviceAccountJsonWith(fields: Record<string, unknown>): string {
|
|
296
|
+
return JSON.stringify({
|
|
297
|
+
client_email: "poller@example.iam.gserviceaccount.com",
|
|
298
|
+
private_key: privateKey,
|
|
299
|
+
...fields,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
describe("GoogleSecOpsClient alerts request parameters", () => {
|
|
304
|
+
test("sends exactly the three documented field paths and nothing else", async () => {
|
|
305
|
+
const { client, requests } = makeClient();
|
|
306
|
+
|
|
307
|
+
const result: FetchAlertsResult = await client.fetchDetectionAlerts({
|
|
308
|
+
startTime: WINDOW_START,
|
|
309
|
+
endTime: WINDOW_END,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/*
|
|
313
|
+
* The call genuinely completed against the canonical envelope, so what
|
|
314
|
+
* follows is the shape of a request Chronicle answered, not the shape
|
|
315
|
+
* of one that fell over half-built.
|
|
316
|
+
*/
|
|
317
|
+
expect(result.alerts).toEqual([{ id: "alert-1" }]);
|
|
318
|
+
expect(result.complete).toBe(true);
|
|
319
|
+
|
|
320
|
+
// One token exchange, one Chronicle call, nothing else on the wire.
|
|
321
|
+
expect(requests).toHaveLength(2);
|
|
322
|
+
expect(
|
|
323
|
+
requests.filter((request: RecordedRequest): boolean => {
|
|
324
|
+
return request.url === GOOGLE_TOKEN_URI;
|
|
325
|
+
}),
|
|
326
|
+
).toHaveLength(1);
|
|
327
|
+
|
|
328
|
+
expect(sortedQueryKeysOf(alertsRequestIn(requests))).toEqual(
|
|
329
|
+
EXPECTED_QUERY_KEYS,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("pins both ends of the window as RFC 3339 instants, read back by name", async () => {
|
|
334
|
+
const { client, requests } = makeClient();
|
|
335
|
+
|
|
336
|
+
await client.fetchDetectionAlerts({
|
|
337
|
+
startTime: WINDOW_START,
|
|
338
|
+
endTime: WINDOW_END,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const params: URLSearchParams = queryParamsOf(alertsRequestIn(requests));
|
|
342
|
+
|
|
343
|
+
expect(params.get("timeRange.startTime")).toBe("2026-08-21T09:00:00.000Z");
|
|
344
|
+
expect(params.get("timeRange.endTime")).toBe("2026-08-21T10:00:00.000Z");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("defaults maxReturnedAlerts to 1000 and honours an explicit ceiling without adding a parameter", async () => {
|
|
348
|
+
const defaultRun: {
|
|
349
|
+
client: GoogleSecOpsClient;
|
|
350
|
+
requests: Array<RecordedRequest>;
|
|
351
|
+
} = makeClient();
|
|
352
|
+
|
|
353
|
+
await defaultRun.client.fetchDetectionAlerts({
|
|
354
|
+
startTime: WINDOW_START,
|
|
355
|
+
endTime: WINDOW_END,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(
|
|
359
|
+
queryParamsOf(alertsRequestIn(defaultRun.requests)).get(
|
|
360
|
+
"alertListOptions.maxReturnedAlerts",
|
|
361
|
+
),
|
|
362
|
+
).toBe("1000");
|
|
363
|
+
|
|
364
|
+
const cappedRun: {
|
|
365
|
+
client: GoogleSecOpsClient;
|
|
366
|
+
requests: Array<RecordedRequest>;
|
|
367
|
+
} = makeClient();
|
|
368
|
+
|
|
369
|
+
await cappedRun.client.fetchDetectionAlerts({
|
|
370
|
+
startTime: WINDOW_START,
|
|
371
|
+
endTime: WINDOW_END,
|
|
372
|
+
maxAlerts: 50,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const cappedRequest: RecordedRequest = alertsRequestIn(cappedRun.requests);
|
|
376
|
+
|
|
377
|
+
expect(
|
|
378
|
+
queryParamsOf(cappedRequest).get("alertListOptions.maxReturnedAlerts"),
|
|
379
|
+
).toBe("50");
|
|
380
|
+
|
|
381
|
+
/*
|
|
382
|
+
* A ceiling is a value, not an extra knob. This endpoint has no
|
|
383
|
+
* pagination of any kind, so nothing about asking for fewer alerts may
|
|
384
|
+
* introduce a second parameter.
|
|
385
|
+
*/
|
|
386
|
+
expect(sortedQueryKeysOf(cappedRequest)).toEqual(EXPECTED_QUERY_KEYS);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("never sends pageSize, snapshotQuery or baselineQuery", async () => {
|
|
390
|
+
const { client, requests } = makeClient();
|
|
391
|
+
|
|
392
|
+
await client.fetchDetectionAlerts({
|
|
393
|
+
startTime: WINDOW_START,
|
|
394
|
+
endTime: WINDOW_END,
|
|
395
|
+
maxAlerts: 25,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const request: RecordedRequest = alertsRequestIn(requests);
|
|
399
|
+
const params: URLSearchParams = queryParamsOf(request);
|
|
400
|
+
|
|
401
|
+
/*
|
|
402
|
+
* First, that there is a query string at all. Every assertion below is
|
|
403
|
+
* an absence check, and absence checks pass just as happily against a
|
|
404
|
+
* URL that lost its parameters entirely.
|
|
405
|
+
*/
|
|
406
|
+
expect(sortedQueryKeysOf(request)).toEqual(EXPECTED_QUERY_KEYS);
|
|
407
|
+
|
|
408
|
+
/*
|
|
409
|
+
* The regression tripwire. `pageSize` has no rename target — this
|
|
410
|
+
* endpoint has no pageSize, no pageToken and no nextPageToken — so its
|
|
411
|
+
* presence in any form is the exact production 400 coming back.
|
|
412
|
+
*/
|
|
413
|
+
expect(params.has("pageSize")).toBe(false);
|
|
414
|
+
expect(params.has("pageToken")).toBe(false);
|
|
415
|
+
|
|
416
|
+
/*
|
|
417
|
+
* Spec conflict C2, open and deliberately unsettled here. The reference
|
|
418
|
+
* doc prefixes snapshotQuery with `Required.`, but that is a
|
|
419
|
+
* field_behavior annotation the HTTP transcoder does not enforce, and
|
|
420
|
+
* this service validates queries in-band (validSnapshotQuery /
|
|
421
|
+
* queryValidationErrors) instead of rejecting them. We omit it, because
|
|
422
|
+
* the only concrete catch-all candidate is Google's own SDK default
|
|
423
|
+
* `feedback_summary.status != "CLOSED"`, which silently drops every
|
|
424
|
+
* CLOSED alert — trading a loud, testable 400 for permanent invisible
|
|
425
|
+
* data loss. Omission fails loudly if it turns out to be enforced.
|
|
426
|
+
*
|
|
427
|
+
* Settling this needs one live call against a real tenant, with and
|
|
428
|
+
* without the parameter, comparing HTTP status and baselineAlertsCount.
|
|
429
|
+
* Until that runs, this assertion is the record of the decision and the
|
|
430
|
+
* first line to change if the tenant disagrees.
|
|
431
|
+
*/
|
|
432
|
+
expect(params.has("snapshotQuery")).toBe(false);
|
|
433
|
+
|
|
434
|
+
/*
|
|
435
|
+
* baselineQuery exists only to reuse a cached baseline across repeated
|
|
436
|
+
* requests, and a single-pass poller has nothing to reuse.
|
|
437
|
+
*/
|
|
438
|
+
expect(params.has("baselineQuery")).toBe(false);
|
|
439
|
+
|
|
440
|
+
/*
|
|
441
|
+
* The two enum-valued preferences, which take AlertsFeaturePreference
|
|
442
|
+
* strings rather than booleans: sending either as true/false produces
|
|
443
|
+
* the same class of 400 being fixed here.
|
|
444
|
+
*/
|
|
445
|
+
expect(params.has("enableCache")).toBe(false);
|
|
446
|
+
expect(params.has("includeNonAlertingDetections")).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("GoogleSecOpsClient alerts request transport", () => {
|
|
451
|
+
test("GETs with no body, and carries only Accept and Bearer authorization", async () => {
|
|
452
|
+
const { client, requests } = makeClient();
|
|
453
|
+
|
|
454
|
+
await client.fetchDetectionAlerts({
|
|
455
|
+
startTime: WINDOW_START,
|
|
456
|
+
endTime: WINDOW_END,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const request: RecordedRequest = alertsRequestIn(requests);
|
|
460
|
+
|
|
461
|
+
expect(request.method).toBe("GET");
|
|
462
|
+
expect(request.body).toBeUndefined();
|
|
463
|
+
|
|
464
|
+
/*
|
|
465
|
+
* The closed header set, which is also how "no Content-Type on a
|
|
466
|
+
* bodyless GET" is asserted without naming the header it must not send.
|
|
467
|
+
*/
|
|
468
|
+
expect(Object.keys(request.headers).sort()).toEqual([
|
|
469
|
+
"Accept",
|
|
470
|
+
"Authorization",
|
|
471
|
+
]);
|
|
472
|
+
expect(request.headers["Accept"]).toBe("application/json");
|
|
473
|
+
expect(request.headers["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("addresses the region's Chronicle host at the legacy alerts view, query after the path", async () => {
|
|
477
|
+
const { client, requests } = makeClient();
|
|
478
|
+
|
|
479
|
+
await client.fetchDetectionAlerts({
|
|
480
|
+
startTime: WINDOW_START,
|
|
481
|
+
endTime: WINDOW_END,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const url: URL = new URL(alertsRequestIn(requests).url);
|
|
485
|
+
|
|
486
|
+
expect(url.origin).toBe("https://us-chronicle.googleapis.com");
|
|
487
|
+
expect(url.pathname).toBe(
|
|
488
|
+
`/v1alpha/${INSTANCE}/legacy:legacyFetchAlertsView`,
|
|
489
|
+
);
|
|
490
|
+
expect(url.search.startsWith("?")).toBe(true);
|
|
491
|
+
|
|
492
|
+
// Nothing may fall off the end of the path into a fragment.
|
|
493
|
+
expect(url.hash).toBe("");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("leaves the dot in a field path literal and percent-encodes the colon in a timestamp", async () => {
|
|
497
|
+
const { client, requests } = makeClient();
|
|
498
|
+
|
|
499
|
+
await client.fetchDetectionAlerts({
|
|
500
|
+
startTime: WINDOW_START,
|
|
501
|
+
endTime: WINDOW_END,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const raw: Map<string, string> = rawQueryPairsOf(alertsRequestIn(requests));
|
|
505
|
+
|
|
506
|
+
/*
|
|
507
|
+
* Undecoded keys, compared against the same list the decoded keys are
|
|
508
|
+
* compared against: identical means the `.` went out as a `.` rather
|
|
509
|
+
* than as %2E, which is what the transcoder binds on.
|
|
510
|
+
*/
|
|
511
|
+
expect(sortedKeysOf(raw)).toEqual(EXPECTED_QUERY_KEYS);
|
|
512
|
+
|
|
513
|
+
expect(raw.get("timeRange.startTime")).toBe("2026-08-21T09%3A00%3A00.000Z");
|
|
514
|
+
expect(raw.get("timeRange.endTime")).toBe("2026-08-21T10%3A00%3A00.000Z");
|
|
515
|
+
expect(raw.get("alertListOptions.maxReturnedAlerts")).toBe("1000");
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe("GoogleSecOpsClient.getApiBaseUrl", () => {
|
|
520
|
+
test("builds the regional host and the v1alpha instance path for us and europe", () => {
|
|
521
|
+
const usClient: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
522
|
+
region: "us",
|
|
523
|
+
instanceResourceName: INSTANCE,
|
|
524
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
525
|
+
fetchImplementation: neverCalledFetch(),
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(usClient.getApiBaseUrl()).toBe(
|
|
529
|
+
`https://us-chronicle.googleapis.com/v1alpha/${INSTANCE}`,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const europeInstance: string =
|
|
533
|
+
"projects/my-project/locations/europe/instances/3f0a-instance";
|
|
534
|
+
|
|
535
|
+
const europeClient: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
536
|
+
region: "europe",
|
|
537
|
+
instanceResourceName: europeInstance,
|
|
538
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
539
|
+
fetchImplementation: neverCalledFetch(),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(europeClient.getApiBaseUrl()).toBe(
|
|
543
|
+
`https://europe-chronicle.googleapis.com/v1alpha/${europeInstance}`,
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe("GoogleSecOpsClient region allowlist", () => {
|
|
549
|
+
test("accepts every documented prefix and builds that region's host from it", () => {
|
|
550
|
+
expect(DOCUMENTED_REGION_PREFIXES).toHaveLength(22);
|
|
551
|
+
|
|
552
|
+
for (const region of DOCUMENTED_REGION_PREFIXES) {
|
|
553
|
+
expect(() => {
|
|
554
|
+
return GoogleSecOpsClient.validateRegion(region);
|
|
555
|
+
}).not.toThrow();
|
|
556
|
+
|
|
557
|
+
const instanceResourceName: string = `projects/my-project/locations/${region}/instances/3f0a-instance`;
|
|
558
|
+
|
|
559
|
+
const client: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
560
|
+
region: region,
|
|
561
|
+
instanceResourceName: instanceResourceName,
|
|
562
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
563
|
+
fetchImplementation: neverCalledFetch(),
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(client.getApiBaseUrl()).toBe(
|
|
567
|
+
`https://${region}-chronicle.googleapis.com/v1alpha/${instanceResourceName}`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("rejects regions that merely look plausible", () => {
|
|
573
|
+
/*
|
|
574
|
+
* *.googleapis.com is a DNS wildcard, so every one of these resolves to
|
|
575
|
+
* a Google frontend and answers with an HTML 404 rather than an API
|
|
576
|
+
* error. A shape regex would admit them all and turn a typo into a
|
|
577
|
+
* parse failure; the allowlist is what makes it read as a bad region.
|
|
578
|
+
*/
|
|
579
|
+
const rejected: Array<string> = [
|
|
580
|
+
"",
|
|
581
|
+
"US",
|
|
582
|
+
"EU",
|
|
583
|
+
"Europe",
|
|
584
|
+
"us-central1",
|
|
585
|
+
"use1",
|
|
586
|
+
"us-",
|
|
587
|
+
"us----",
|
|
588
|
+
"us east",
|
|
589
|
+
"us_",
|
|
590
|
+
"us/../etc",
|
|
591
|
+
"us.chronicle",
|
|
592
|
+
// Real Google Cloud regions that Chronicle does not serve.
|
|
593
|
+
"europe-west1",
|
|
594
|
+
"asia-east2",
|
|
595
|
+
"me-west2",
|
|
596
|
+
"northamerica-northeast1",
|
|
597
|
+
];
|
|
598
|
+
|
|
599
|
+
for (const region of rejected) {
|
|
600
|
+
expect(() => {
|
|
601
|
+
return GoogleSecOpsClient.validateRegion(region);
|
|
602
|
+
}).toThrow(BadDataException);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe("GoogleSecOpsClient region and instance location cross-check", () => {
|
|
608
|
+
test("rejects a region that disagrees with the instance's locations segment", () => {
|
|
609
|
+
const mismatched: Array<{ region: string; location: string }> = [
|
|
610
|
+
{ region: "us", location: "europe" },
|
|
611
|
+
{ region: "us", location: "eu" },
|
|
612
|
+
{ region: "europe", location: "us" },
|
|
613
|
+
{ region: "asia-south1", location: "asia-southeast1" },
|
|
614
|
+
{ region: "me-central1", location: "me-central2" },
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
for (const pair of mismatched) {
|
|
618
|
+
expect(() => {
|
|
619
|
+
return new GoogleSecOpsClient({
|
|
620
|
+
region: pair.region,
|
|
621
|
+
instanceResourceName: `projects/my-project/locations/${pair.location}/instances/3f0a-instance`,
|
|
622
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
623
|
+
fetchImplementation: neverCalledFetch(),
|
|
624
|
+
});
|
|
625
|
+
}).toThrow(BadDataException);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("distinguishes an unsupported region from a region that disagrees with the instance", () => {
|
|
630
|
+
/*
|
|
631
|
+
* Both are BadDataException, and an operator only ever sees the text.
|
|
632
|
+
* Two different misconfigurations must not read as the same one.
|
|
633
|
+
*/
|
|
634
|
+
const unsupportedRegionMessage: string = messageThrownBy(
|
|
635
|
+
(): GoogleSecOpsClient => {
|
|
636
|
+
return new GoogleSecOpsClient({
|
|
637
|
+
region: "us-central1",
|
|
638
|
+
instanceResourceName: INSTANCE,
|
|
639
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
640
|
+
fetchImplementation: neverCalledFetch(),
|
|
641
|
+
});
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const mismatchMessage: string = messageThrownBy((): GoogleSecOpsClient => {
|
|
646
|
+
return new GoogleSecOpsClient({
|
|
647
|
+
region: "us",
|
|
648
|
+
instanceResourceName:
|
|
649
|
+
"projects/my-project/locations/europe/instances/3f0a-instance",
|
|
650
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
651
|
+
fetchImplementation: neverCalledFetch(),
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
expect(unsupportedRegionMessage.length).toBeGreaterThan(0);
|
|
656
|
+
expect(mismatchMessage.length).toBeGreaterThan(0);
|
|
657
|
+
expect(unsupportedRegionMessage).not.toBe(mismatchMessage);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("treats eu and europe as one place, in both directions", () => {
|
|
661
|
+
/*
|
|
662
|
+
* europe-chronicle.googleapis.com is a documented live host while the
|
|
663
|
+
* migration guide names the same multi-region's location code `eu`. A
|
|
664
|
+
* strict identity check here rejects every valid EU tenant, so this is
|
|
665
|
+
* a regression guard on real customers rather than an edge case.
|
|
666
|
+
*/
|
|
667
|
+
const euRegionEuropeInstance: string =
|
|
668
|
+
"projects/my-project/locations/europe/instances/3f0a-instance";
|
|
669
|
+
|
|
670
|
+
const euClient: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
671
|
+
region: "eu",
|
|
672
|
+
instanceResourceName: euRegionEuropeInstance,
|
|
673
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
674
|
+
fetchImplementation: neverCalledFetch(),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// The host follows the region; the path keeps the instance verbatim.
|
|
678
|
+
expect(euClient.getApiBaseUrl()).toBe(
|
|
679
|
+
`https://eu-chronicle.googleapis.com/v1alpha/${euRegionEuropeInstance}`,
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const europeRegionEuInstance: string =
|
|
683
|
+
"projects/my-project/locations/eu/instances/3f0a-instance";
|
|
684
|
+
|
|
685
|
+
const europeClient: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
686
|
+
region: "europe",
|
|
687
|
+
instanceResourceName: europeRegionEuInstance,
|
|
688
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
689
|
+
fetchImplementation: neverCalledFetch(),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
expect(europeClient.getApiBaseUrl()).toBe(
|
|
693
|
+
`https://europe-chronicle.googleapis.com/v1alpha/${europeRegionEuInstance}`,
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe("GoogleSecOpsClient service account token_uri", () => {
|
|
699
|
+
test("accepts Google token endpoints and returns them unchanged", () => {
|
|
700
|
+
const accepted: Array<string> = [
|
|
701
|
+
"https://oauth2.googleapis.com/token",
|
|
702
|
+
"https://accounts.google.com/o/oauth2/token",
|
|
703
|
+
"https://us-chronicle.googleapis.com/token",
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
for (const tokenUri of accepted) {
|
|
707
|
+
const credentials: GoogleServiceAccountCredentials =
|
|
708
|
+
GoogleSecOpsClient.parseServiceAccountJson(
|
|
709
|
+
serviceAccountJsonWith({ token_uri: tokenUri }),
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
expect(credentials.tokenUri).toBe(tokenUri);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("defaults to Google's token endpoint when the field is absent or empty, and the default passes its own allowlist", () => {
|
|
717
|
+
const absent: GoogleServiceAccountCredentials =
|
|
718
|
+
GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJsonWith({}));
|
|
719
|
+
|
|
720
|
+
expect(absent.tokenUri).toBe("https://oauth2.googleapis.com/token");
|
|
721
|
+
|
|
722
|
+
const empty: GoogleServiceAccountCredentials =
|
|
723
|
+
GoogleSecOpsClient.parseServiceAccountJson(
|
|
724
|
+
serviceAccountJsonWith({ token_uri: "" }),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
expect(empty.tokenUri).toBe("https://oauth2.googleapis.com/token");
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("rejects a token_uri that is not an https URL on a Google host", () => {
|
|
731
|
+
/*
|
|
732
|
+
* The token endpoint is customer-supplied and the first 500 characters
|
|
733
|
+
* of whatever answers it are echoed into lastError and rendered in the
|
|
734
|
+
* dashboard. Unpinned, that is an exfiltration primitive rather than a
|
|
735
|
+
* blind SSRF — hence the metadata address and the lookalike hosts.
|
|
736
|
+
*/
|
|
737
|
+
const rejected: Array<string> = [
|
|
738
|
+
"http://oauth2.googleapis.com/token",
|
|
739
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
740
|
+
"ftp://oauth2.googleapis.com/token",
|
|
741
|
+
"file:///etc/passwd",
|
|
742
|
+
"https://attacker@oauth2.googleapis.com/token",
|
|
743
|
+
"https://evil.example.com/token",
|
|
744
|
+
// Suffix lookalikes: neither is a subdomain of googleapis.com.
|
|
745
|
+
"https://evil-googleapis.com/token",
|
|
746
|
+
"https://oauth2.googleapis.com.evil.example/token",
|
|
747
|
+
// Not absolute at all.
|
|
748
|
+
"oauth2.googleapis.com/token",
|
|
749
|
+
"//oauth2.googleapis.com/token",
|
|
750
|
+
"/token",
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
for (const tokenUri of rejected) {
|
|
754
|
+
expect(() => {
|
|
755
|
+
return GoogleSecOpsClient.parseServiceAccountJson(
|
|
756
|
+
serviceAccountJsonWith({ token_uri: tokenUri }),
|
|
757
|
+
);
|
|
758
|
+
}).toThrow(BadDataException);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("GoogleSecOpsClient service account JSON", () => {
|
|
764
|
+
test("throws BadDataException, never a raw TypeError, for a root that is not an object", () => {
|
|
765
|
+
/*
|
|
766
|
+
* JSON.parse("null") succeeds, and the property reads that follow used
|
|
767
|
+
* to throw a bare TypeError out of the public create/update API — a 500
|
|
768
|
+
* where the caller's own input deserves a 400.
|
|
769
|
+
*/
|
|
770
|
+
const nonObjectRoots: Array<string> = [
|
|
771
|
+
"null",
|
|
772
|
+
"[]",
|
|
773
|
+
"[{}]",
|
|
774
|
+
"42",
|
|
775
|
+
'"a string"',
|
|
776
|
+
"true",
|
|
777
|
+
];
|
|
778
|
+
|
|
779
|
+
for (const serviceAccountJson of nonObjectRoots) {
|
|
780
|
+
const thrown: unknown = errorThrownBy(
|
|
781
|
+
(): GoogleServiceAccountCredentials => {
|
|
782
|
+
return GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJson);
|
|
783
|
+
},
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
expect(thrown).toBeInstanceOf(BadDataException);
|
|
787
|
+
expect(thrown).not.toBeInstanceOf(TypeError);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("surfaces the same BadDataException through the constructor", () => {
|
|
792
|
+
const thrown: unknown = errorThrownBy((): GoogleSecOpsClient => {
|
|
793
|
+
return new GoogleSecOpsClient({
|
|
794
|
+
region: "us",
|
|
795
|
+
instanceResourceName: INSTANCE,
|
|
796
|
+
serviceAccountJson: "null",
|
|
797
|
+
fetchImplementation: neverCalledFetch(),
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
expect(thrown).toBeInstanceOf(BadDataException);
|
|
802
|
+
expect(thrown).not.toBeInstanceOf(TypeError);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("rejects non-string client_email and private_key instead of coercing them", () => {
|
|
806
|
+
/*
|
|
807
|
+
* String({}) is "[object Object]": non-empty, past every emptiness
|
|
808
|
+
* check, and fatal an hour later inside jwt.sign on the cron.
|
|
809
|
+
*/
|
|
810
|
+
const rejected: Array<string> = [
|
|
811
|
+
JSON.stringify({ client_email: "a@b.c", private_key: {} }),
|
|
812
|
+
JSON.stringify({ client_email: "a@b.c", private_key: 12345 }),
|
|
813
|
+
JSON.stringify({ client_email: "a@b.c", private_key: null }),
|
|
814
|
+
JSON.stringify({ client_email: {}, private_key: privateKey }),
|
|
815
|
+
JSON.stringify({ client_email: 42, private_key: privateKey }),
|
|
816
|
+
JSON.stringify({ private_key: privateKey }),
|
|
817
|
+
JSON.stringify({ client_email: "a@b.c" }),
|
|
818
|
+
"not json at all",
|
|
819
|
+
];
|
|
820
|
+
|
|
821
|
+
for (const serviceAccountJson of rejected) {
|
|
822
|
+
expect(() => {
|
|
823
|
+
return GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJson);
|
|
824
|
+
}).toThrow(BadDataException);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("rejects a private_key that no PEM decoder can read", () => {
|
|
829
|
+
/*
|
|
830
|
+
* A double-escaped or truncated key saves cleanly today and fails an
|
|
831
|
+
* hour later inside jwt.sign as an unwrapped OpenSSL decoder error, on
|
|
832
|
+
* the cron — exactly the class of failure save-time validation exists
|
|
833
|
+
* to prevent.
|
|
834
|
+
*/
|
|
835
|
+
const unreadableKeys: Array<string> = [
|
|
836
|
+
privateKey.replace(/\n/g, "\\n"),
|
|
837
|
+
privateKey.slice(0, 120),
|
|
838
|
+
"-----BEGIN PRIVATE KEY-----\nnot-base64\n-----END PRIVATE KEY-----\n",
|
|
839
|
+
"[object Object]",
|
|
840
|
+
"not a key at all",
|
|
841
|
+
" ",
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
for (const key of unreadableKeys) {
|
|
845
|
+
expect(() => {
|
|
846
|
+
return GoogleSecOpsClient.parseServiceAccountJson(
|
|
847
|
+
serviceAccountJsonWith({ private_key: key }),
|
|
848
|
+
);
|
|
849
|
+
}).toThrow(BadDataException);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// The control: a real key still parses, and comes back byte-identical.
|
|
853
|
+
const credentials: GoogleServiceAccountCredentials =
|
|
854
|
+
GoogleSecOpsClient.parseServiceAccountJson(SERVICE_ACCOUNT_JSON);
|
|
855
|
+
|
|
856
|
+
expect(credentials.privateKey).toBe(privateKey);
|
|
857
|
+
expect(credentials.clientEmail).toBe(
|
|
858
|
+
"poller@example.iam.gserviceaccount.com",
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe("GoogleSecOpsClient instance resource name", () => {
|
|
864
|
+
test("accepts the documented four-segment shape only", () => {
|
|
865
|
+
expect(() => {
|
|
866
|
+
return GoogleSecOpsClient.validateInstanceResourceName(INSTANCE);
|
|
867
|
+
}).not.toThrow();
|
|
868
|
+
|
|
869
|
+
const rejected: Array<string> = [
|
|
870
|
+
"",
|
|
871
|
+
"instances/abc",
|
|
872
|
+
"projects/p/instances/i",
|
|
873
|
+
"projects/p/locations/l/instances/i/extra",
|
|
874
|
+
"projects/p/locations/l/instances/",
|
|
875
|
+
"projects//locations/l/instances/i",
|
|
876
|
+
];
|
|
877
|
+
|
|
878
|
+
for (const name of rejected) {
|
|
879
|
+
expect(() => {
|
|
880
|
+
return GoogleSecOpsClient.validateInstanceResourceName(name);
|
|
881
|
+
}).toThrow(BadDataException);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("rejects a name carrying URL syntax that would reshape the request", () => {
|
|
886
|
+
/*
|
|
887
|
+
* The host is fixed by getApiBaseUrl, so none of these is a URL
|
|
888
|
+
* takeover — but `#` truncates the path and drops the entire query
|
|
889
|
+
* string, `?` injects parameters ahead of the real ones, and `%` lets a
|
|
890
|
+
* segment smuggle an encoded separator past this check. All three are
|
|
891
|
+
* config-controlled request shaping.
|
|
892
|
+
*/
|
|
893
|
+
const rejected: Array<string> = [
|
|
894
|
+
"projects/my-project/locations/us/instances/3f0a#fragment",
|
|
895
|
+
"projects/my-project/locations/us/instances/3f0a?alt=json",
|
|
896
|
+
"projects/my-project/locations/us/instances/3f0a&alt=json",
|
|
897
|
+
"projects/my-project/locations/us/instances/%23",
|
|
898
|
+
"projects/my-project#/locations/us/instances/3f0a",
|
|
899
|
+
"projects/my-project/locations/us/instances/3f0a instance",
|
|
900
|
+
];
|
|
901
|
+
|
|
902
|
+
for (const name of rejected) {
|
|
903
|
+
expect(() => {
|
|
904
|
+
return GoogleSecOpsClient.validateInstanceResourceName(name);
|
|
905
|
+
}).toThrow(BadDataException);
|
|
906
|
+
|
|
907
|
+
expect(() => {
|
|
908
|
+
return new GoogleSecOpsClient({
|
|
909
|
+
region: "us",
|
|
910
|
+
instanceResourceName: name,
|
|
911
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
912
|
+
fetchImplementation: neverCalledFetch(),
|
|
913
|
+
});
|
|
914
|
+
}).toThrow(BadDataException);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("percent-encodes each surviving segment losslessly into the request path", async () => {
|
|
919
|
+
/*
|
|
920
|
+
* The other half of the same defect: what the shape check does admit
|
|
921
|
+
* still has to reach the wire as path segments rather than as syntax.
|
|
922
|
+
*/
|
|
923
|
+
const oddInstance: string =
|
|
924
|
+
"projects/my+project/locations/us/instances/3f0a+instance";
|
|
925
|
+
|
|
926
|
+
const { client, requests } = makeClient({
|
|
927
|
+
instanceResourceName: oddInstance,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
await client.fetchDetectionAlerts({
|
|
931
|
+
startTime: WINDOW_START,
|
|
932
|
+
endTime: WINDOW_END,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
const url: URL = new URL(alertsRequestIn(requests).url);
|
|
936
|
+
|
|
937
|
+
expect(url.origin).toBe("https://us-chronicle.googleapis.com");
|
|
938
|
+
expect(url.pathname).toBe(
|
|
939
|
+
"/v1alpha/projects/my%2Bproject/locations/us/instances/3f0a%2Binstance/legacy:legacyFetchAlertsView",
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
const decodedPath: string = url.pathname
|
|
943
|
+
.split("/")
|
|
944
|
+
.map((segment: string): string => {
|
|
945
|
+
return decodeURIComponent(segment);
|
|
946
|
+
})
|
|
947
|
+
.join("/");
|
|
948
|
+
|
|
949
|
+
// Encoding happened, and it round-trips back to the configured name.
|
|
950
|
+
expect(url.pathname).not.toBe(decodedPath);
|
|
951
|
+
expect(decodedPath).toBe(
|
|
952
|
+
`/v1alpha/${oddInstance}/legacy:legacyFetchAlertsView`,
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
// And none of it leaked into the query or off the end of the path.
|
|
956
|
+
expect(sortedQueryKeysOf(alertsRequestIn(requests))).toEqual(
|
|
957
|
+
EXPECTED_QUERY_KEYS,
|
|
958
|
+
);
|
|
959
|
+
expect(url.hash).toBe("");
|
|
960
|
+
});
|
|
961
|
+
});
|