@oneuptime/common 12.0.27 → 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/AnalyticsModels/RumSessionChunk.ts +52 -0
- package/Models/DatabaseModels/CloudResource.ts +17 -1
- package/Models/DatabaseModels/DashboardDomain.ts +43 -0
- package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
- package/Models/DatabaseModels/RumApplication.ts +35 -2
- 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/API/TelemetryAPI.ts +104 -15
- 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/SessionReplay/SessionReplayGateCache.ts +12 -5
- package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
- 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/DatabaseModels/SessionReplayModels.test.ts +81 -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/API/SessionReplayAPI.test.ts +232 -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 +76 -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/AnalyticsModels/RumSessionChunk.js +48 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
- package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
- 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/RumApplication.js +37 -3
- package/build/dist/Models/DatabaseModels/RumApplication.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/API/TelemetryAPI.js +67 -14
- package/build/dist/Server/API/TelemetryAPI.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/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.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,1246 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "crypto";
|
|
2
|
+
import GoogleSecOpsConnection from "../../../../Models/DatabaseModels/GoogleSecOpsConnection";
|
|
3
|
+
import GoogleSecOpsConnectionService from "../../../../Server/Services/GoogleSecOpsConnectionService";
|
|
4
|
+
import OTelIngestService, {
|
|
5
|
+
TelemetryServiceMetadata,
|
|
6
|
+
} from "../../../../Server/Services/OpenTelemetryIngestService";
|
|
7
|
+
import SecurityEventService from "../../../../Server/Services/SecurityEventService";
|
|
8
|
+
import logger from "../../../../Server/Utils/Logger";
|
|
9
|
+
import GoogleSecOpsClient, {
|
|
10
|
+
FetchAlertsResult,
|
|
11
|
+
FetchLike,
|
|
12
|
+
FetchResponseLike,
|
|
13
|
+
} from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient";
|
|
14
|
+
import GoogleSecOpsPoller from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller";
|
|
15
|
+
import ThreatIntelEnricher, {
|
|
16
|
+
EnrichmentResult,
|
|
17
|
+
} from "../../../../Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelEnricher";
|
|
18
|
+
import LIMIT_MAX from "../../../../Types/Database/LimitMax";
|
|
19
|
+
import GoogleSecOpsAlertNormalizer from "../../../../Utils/SecurityEvent/GoogleSecOpsAlertNormalizer";
|
|
20
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
21
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
22
|
+
import NormalizedSecurityEvent from "../../../../Types/SecurityEvent/NormalizedSecurityEvent";
|
|
23
|
+
import OcsfSeverity from "../../../../Types/SecurityEvent/OcsfSeverity";
|
|
24
|
+
import ServiceType from "../../../../Types/Telemetry/ServiceType";
|
|
25
|
+
import { getJestSpyOn } from "../../../Spy";
|
|
26
|
+
import { afterEach, describe, expect, jest, test } from "@jest/globals";
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* The cursor is the only thing standing between a broken Google SecOps
|
|
30
|
+
* poll and permanent, silent data loss.
|
|
31
|
+
*
|
|
32
|
+
* legacyFetchAlertsView is a server-streaming method whose 200 body is an
|
|
33
|
+
* array of chunks with the alerts two levels down at chunk.alerts.alerts[].
|
|
34
|
+
* The client used to look for a top-level `alerts` array, find nothing,
|
|
35
|
+
* and return [] — and the poller read [] as "a quiet window", advanced the
|
|
36
|
+
* cursor past it, and cleared lastError. Every tick, for every tenant, a
|
|
37
|
+
* totally broken connector was byte-identical in the database to a healthy
|
|
38
|
+
* one with nothing to report.
|
|
39
|
+
*
|
|
40
|
+
* So the behaviors pinned here are the ones that decide whether a failure
|
|
41
|
+
* is recoverable: which outcomes may move the cursor forward, which must
|
|
42
|
+
* hold it where it is, what the poll window resolves to when the stored
|
|
43
|
+
* cursor cannot be trusted, and whether the number of events actually
|
|
44
|
+
* ingested is observable from outside the process at all.
|
|
45
|
+
*
|
|
46
|
+
* Everything runs through the real GoogleSecOpsClient over an injected
|
|
47
|
+
* transport wherever the response shape is the thing under test, so the
|
|
48
|
+
* chunk envelope these tests assert on is the one Chronicle sends.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
52
|
+
"11111111-1111-4111-8111-111111111111",
|
|
53
|
+
);
|
|
54
|
+
const CONNECTION_ID: ObjectID = new ObjectID(
|
|
55
|
+
"22222222-2222-4222-8222-222222222222",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const INSTANCE_RESOURCE_NAME: string =
|
|
59
|
+
"projects/my-project/locations/us/instances/3f0a-instance";
|
|
60
|
+
|
|
61
|
+
// Not Chronicle: the transport below routes on this to tell the two apart.
|
|
62
|
+
const GOOGLE_TOKEN_URI: string = "https://oauth2.googleapis.com/token";
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* A real RS256 key, because the client genuinely signs a JWT assertion on
|
|
66
|
+
* the way to the token endpoint and validates the PEM at construction; a
|
|
67
|
+
* fake key fails there instead of at the behavior under test.
|
|
68
|
+
*/
|
|
69
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
70
|
+
modulusLength: 2048,
|
|
71
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
72
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const SERVICE_ACCOUNT_JSON: string = JSON.stringify({
|
|
76
|
+
client_email: "poller@example.iam.gserviceaccount.com",
|
|
77
|
+
private_key: privateKey,
|
|
78
|
+
token_uri: GOOGLE_TOKEN_URI,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const MINUTE_IN_MS: number = 60 * 1000;
|
|
82
|
+
const HOUR_IN_MS: number = 60 * MINUTE_IN_MS;
|
|
83
|
+
const DAY_IN_MS: number = 24 * HOUR_IN_MS;
|
|
84
|
+
|
|
85
|
+
const DETECTION_FINDING_CLASS_UID: number = 2004;
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
* Captured at import time, before any spy replaces it.
|
|
89
|
+
* pollAllDueConnections calls `this.pollConnection(connection)` with no
|
|
90
|
+
* client override, so putting an injected transport under a full tick
|
|
91
|
+
* means spying on pollConnection and delegating straight back to the real
|
|
92
|
+
* implementation with a client attached. Everything inside it — the
|
|
93
|
+
* window arithmetic, the alert gate, the insert, the bookkeeping write —
|
|
94
|
+
* still runs for real.
|
|
95
|
+
*/
|
|
96
|
+
const originalPollConnection: (
|
|
97
|
+
connection: GoogleSecOpsConnection,
|
|
98
|
+
clientOverride?: GoogleSecOpsClient | undefined,
|
|
99
|
+
) => Promise<number> =
|
|
100
|
+
GoogleSecOpsPoller.pollConnection.bind(GoogleSecOpsPoller);
|
|
101
|
+
|
|
102
|
+
interface StubbedResponse {
|
|
103
|
+
status: number;
|
|
104
|
+
body: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface RecordedRequest {
|
|
108
|
+
url: string;
|
|
109
|
+
method: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface RecordedWindow {
|
|
113
|
+
startTime: Date;
|
|
114
|
+
endTime: Date;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The shape of one updateOneById call, for readable assertions.
|
|
118
|
+
interface ConnectionUpdateCall {
|
|
119
|
+
id: ObjectID;
|
|
120
|
+
data: JSONObject;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface CapturedLogs {
|
|
124
|
+
info: Array<string>;
|
|
125
|
+
warn: Array<string>;
|
|
126
|
+
error: Array<string>;
|
|
127
|
+
debug: Array<string>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PollHarness {
|
|
131
|
+
updates: Array<ConnectionUpdateCall>;
|
|
132
|
+
insertedBatches: Array<Array<JSONObject>>;
|
|
133
|
+
logs: CapturedLogs;
|
|
134
|
+
telemetrySpy: ReturnType<typeof getJestSpyOn>;
|
|
135
|
+
insertSpy: ReturnType<typeof getJestSpyOn>;
|
|
136
|
+
enricherSpy: ReturnType<typeof getJestSpyOn>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function makeConnection(): GoogleSecOpsConnection {
|
|
140
|
+
const connection: GoogleSecOpsConnection = new GoogleSecOpsConnection();
|
|
141
|
+
connection._id = CONNECTION_ID.toString();
|
|
142
|
+
connection.projectId = PROJECT_ID;
|
|
143
|
+
connection.name = "Prod tenant";
|
|
144
|
+
connection.region = "us";
|
|
145
|
+
connection.instanceResourceName = INSTANCE_RESOURCE_NAME;
|
|
146
|
+
connection.serviceAccountJson = SERVICE_ACCOUNT_JSON;
|
|
147
|
+
connection.pollIntervalInMinutes = 5;
|
|
148
|
+
// lastPolledAt left unset: never polled, therefore always due.
|
|
149
|
+
return connection;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function makeServiceMetadata(): TelemetryServiceMetadata {
|
|
153
|
+
return {
|
|
154
|
+
serviceName: "Google SecOps",
|
|
155
|
+
primaryEntityId: new ObjectID("33333333-3333-4333-8333-333333333333"),
|
|
156
|
+
primaryEntityType: ServiceType.OpenTelemetry,
|
|
157
|
+
dataRententionInDays: 15,
|
|
158
|
+
serviceRetentionConfig: null,
|
|
159
|
+
serviceRetentionInDays: null,
|
|
160
|
+
projectRetentionConfig: null,
|
|
161
|
+
projectRetentionInDays: 15,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* One alert with its matched UDM sample event, so the finding row carries
|
|
167
|
+
* the entities the detection fired on rather than an empty shell.
|
|
168
|
+
*/
|
|
169
|
+
function alertOne(): JSONObject {
|
|
170
|
+
return {
|
|
171
|
+
id: "alert-1",
|
|
172
|
+
type: "RULE_DETECTION",
|
|
173
|
+
detectionTime: "2026-08-21T09:30:00.000Z",
|
|
174
|
+
detection: [
|
|
175
|
+
{
|
|
176
|
+
ruleName: "Suspicious PowerShell",
|
|
177
|
+
ruleId: "ru_1a2b",
|
|
178
|
+
severity: "HIGH",
|
|
179
|
+
alertState: "ALERTING",
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
collectionElements: [
|
|
183
|
+
{
|
|
184
|
+
label: "e1",
|
|
185
|
+
references: [
|
|
186
|
+
{
|
|
187
|
+
event: {
|
|
188
|
+
metadata: {
|
|
189
|
+
eventType: "PROCESS_LAUNCH",
|
|
190
|
+
eventTimestamp: "2026-08-21T09:29:58.000Z",
|
|
191
|
+
},
|
|
192
|
+
principal: {
|
|
193
|
+
hostname: "workstation-14",
|
|
194
|
+
ip: ["10.1.2.3"],
|
|
195
|
+
user: { userid: "jsmith" },
|
|
196
|
+
},
|
|
197
|
+
target: { ip: ["203.0.113.9"], port: 443 },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function alertTwo(): JSONObject {
|
|
207
|
+
return {
|
|
208
|
+
id: "alert-2",
|
|
209
|
+
type: "RULE_DETECTION",
|
|
210
|
+
detectionTime: "2026-08-21T09:31:00.000Z",
|
|
211
|
+
detection: [
|
|
212
|
+
{
|
|
213
|
+
ruleName: "Impossible travel",
|
|
214
|
+
ruleId: "ru_9z8y",
|
|
215
|
+
severity: "CRITICAL",
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/*
|
|
222
|
+
* A FetchAlertsViewResponse chunk. Not an alert — it is the envelope the
|
|
223
|
+
* alerts arrive inside, and the client's own gate is what keeps it out of
|
|
224
|
+
* the alerts array in the first place.
|
|
225
|
+
*/
|
|
226
|
+
function streamChunk(): JSONObject {
|
|
227
|
+
return { progress: 0.5, complete: false };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function okTokenResponse(): StubbedResponse {
|
|
231
|
+
return {
|
|
232
|
+
status: 200,
|
|
233
|
+
body: JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Transport routed by URL rather than by call order, so a test can read
|
|
239
|
+
* the window off the Chronicle request without counting calls.
|
|
240
|
+
*/
|
|
241
|
+
function makeFetch(responses: {
|
|
242
|
+
token: StubbedResponse;
|
|
243
|
+
alerts: StubbedResponse;
|
|
244
|
+
}): {
|
|
245
|
+
fetchImplementation: FetchLike;
|
|
246
|
+
requests: Array<RecordedRequest>;
|
|
247
|
+
} {
|
|
248
|
+
const requests: Array<RecordedRequest> = [];
|
|
249
|
+
|
|
250
|
+
const fetchImplementation: FetchLike = (
|
|
251
|
+
url: string,
|
|
252
|
+
init: {
|
|
253
|
+
method: string;
|
|
254
|
+
headers: Record<string, string>;
|
|
255
|
+
body?: string | undefined;
|
|
256
|
+
},
|
|
257
|
+
): Promise<FetchResponseLike> => {
|
|
258
|
+
requests.push({ url: url, method: init.method });
|
|
259
|
+
|
|
260
|
+
const response: StubbedResponse =
|
|
261
|
+
url === GOOGLE_TOKEN_URI ? responses.token : responses.alerts;
|
|
262
|
+
|
|
263
|
+
return Promise.resolve({
|
|
264
|
+
ok: response.status >= 200 && response.status < 300,
|
|
265
|
+
status: response.status,
|
|
266
|
+
text: (): Promise<string> => {
|
|
267
|
+
return Promise.resolve(response.body);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return { fetchImplementation: fetchImplementation, requests: requests };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function makeClient(alerts: StubbedResponse): {
|
|
276
|
+
client: GoogleSecOpsClient;
|
|
277
|
+
requests: Array<RecordedRequest>;
|
|
278
|
+
} {
|
|
279
|
+
const { fetchImplementation, requests } = makeFetch({
|
|
280
|
+
token: okTokenResponse(),
|
|
281
|
+
alerts: alerts,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const client: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
285
|
+
region: "us",
|
|
286
|
+
instanceResourceName: INSTANCE_RESOURCE_NAME,
|
|
287
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
288
|
+
fetchImplementation: fetchImplementation,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return { client: client, requests: requests };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* A 200 whose body is the real streaming envelope. */
|
|
295
|
+
function streamingResponse(chunks: Array<JSONObject>): StubbedResponse {
|
|
296
|
+
return { status: 200, body: JSON.stringify(chunks) };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function alertsRequestOf(requests: Array<RecordedRequest>): RecordedRequest {
|
|
300
|
+
const alertsRequests: Array<RecordedRequest> = requests.filter(
|
|
301
|
+
(request: RecordedRequest): boolean => {
|
|
302
|
+
return request.url !== GOOGLE_TOKEN_URI;
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(alertsRequests).toHaveLength(1);
|
|
307
|
+
|
|
308
|
+
return alertsRequests[0]!;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function windowSentTo(requests: Array<RecordedRequest>): {
|
|
312
|
+
startTime: string;
|
|
313
|
+
endTime: string;
|
|
314
|
+
} {
|
|
315
|
+
const params: URLSearchParams = new URL(alertsRequestOf(requests).url)
|
|
316
|
+
.searchParams;
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
startTime: params.get("timeRange.startTime") || "",
|
|
320
|
+
endTime: params.get("timeRange.endTime") || "",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/*
|
|
325
|
+
* What the client hands back for a window that parsed cleanly. Used where
|
|
326
|
+
* the response SHAPE is not the thing under test.
|
|
327
|
+
*/
|
|
328
|
+
function stubbedFetchResult(alerts: Array<JSONObject>): FetchAlertsResult {
|
|
329
|
+
return {
|
|
330
|
+
alerts: alerts,
|
|
331
|
+
complete: true,
|
|
332
|
+
progress: 1,
|
|
333
|
+
truncatedByCount: false,
|
|
334
|
+
truncatedByBytes: false,
|
|
335
|
+
baselineAlertsCount: alerts.length,
|
|
336
|
+
filteredAlertsCount: alerts.length,
|
|
337
|
+
chunkCount: 1,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function makeStubClient(result: FetchAlertsResult): {
|
|
342
|
+
client: GoogleSecOpsClient;
|
|
343
|
+
windows: Array<RecordedWindow>;
|
|
344
|
+
} {
|
|
345
|
+
const windows: Array<RecordedWindow> = [];
|
|
346
|
+
|
|
347
|
+
const client: GoogleSecOpsClient = {
|
|
348
|
+
fetchDetectionAlerts: (data: {
|
|
349
|
+
startTime: Date;
|
|
350
|
+
endTime: Date;
|
|
351
|
+
}): Promise<FetchAlertsResult> => {
|
|
352
|
+
windows.push({ startTime: data.startTime, endTime: data.endTime });
|
|
353
|
+
return Promise.resolve(result);
|
|
354
|
+
},
|
|
355
|
+
} as unknown as GoogleSecOpsClient;
|
|
356
|
+
|
|
357
|
+
return { client: client, windows: windows };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/*
|
|
361
|
+
* A client that fails the way a normalizer fault does not: the alerts
|
|
362
|
+
* never arrive at all.
|
|
363
|
+
*/
|
|
364
|
+
function makeThrowingStubClient(error: Error): GoogleSecOpsClient {
|
|
365
|
+
return {
|
|
366
|
+
fetchDetectionAlerts: (): Promise<FetchAlertsResult> => {
|
|
367
|
+
return Promise.reject(error);
|
|
368
|
+
},
|
|
369
|
+
} as unknown as GoogleSecOpsClient;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function textOf(body: unknown): string {
|
|
373
|
+
if (typeof body === "string") {
|
|
374
|
+
return body;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (body instanceof Error) {
|
|
378
|
+
return body.message;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return String(body);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function captureLogs(): CapturedLogs {
|
|
385
|
+
const logs: CapturedLogs = { info: [], warn: [], error: [], debug: [] };
|
|
386
|
+
|
|
387
|
+
const levels: Array<"info" | "warn" | "error" | "debug"> = [
|
|
388
|
+
"info",
|
|
389
|
+
"warn",
|
|
390
|
+
"error",
|
|
391
|
+
"debug",
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
for (const level of levels) {
|
|
395
|
+
getJestSpyOn(logger, level).mockImplementation(((body: unknown): void => {
|
|
396
|
+
logs[level].push(textOf(body));
|
|
397
|
+
return undefined;
|
|
398
|
+
}) as never);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return logs;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/*
|
|
405
|
+
* Poller lines name the connection; the client's own warnings do not. That
|
|
406
|
+
* is the seam these tests use to assert on what the POLLER said.
|
|
407
|
+
*/
|
|
408
|
+
function linesAboutConnection(lines: Array<string>): Array<string> {
|
|
409
|
+
return lines.filter((line: string): boolean => {
|
|
410
|
+
return line.includes(CONNECTION_ID.toString());
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* Every write the poll would perform, captured instead of performed.
|
|
416
|
+
* Called per test rather than in a beforeEach so no two tests can share a
|
|
417
|
+
* spy or the order they were installed in.
|
|
418
|
+
*/
|
|
419
|
+
function stubPollPath(): PollHarness {
|
|
420
|
+
const updates: Array<ConnectionUpdateCall> = [];
|
|
421
|
+
const insertedBatches: Array<Array<JSONObject>> = [];
|
|
422
|
+
|
|
423
|
+
const telemetrySpy: ReturnType<typeof getJestSpyOn> = getJestSpyOn(
|
|
424
|
+
OTelIngestService,
|
|
425
|
+
"telemetryServiceFromName",
|
|
426
|
+
).mockResolvedValue(makeServiceMetadata() as never);
|
|
427
|
+
|
|
428
|
+
const insertSpy: ReturnType<typeof getJestSpyOn> = getJestSpyOn(
|
|
429
|
+
SecurityEventService,
|
|
430
|
+
"insertJsonRows",
|
|
431
|
+
).mockImplementation(((rows: Array<JSONObject>): Promise<void> => {
|
|
432
|
+
insertedBatches.push(rows);
|
|
433
|
+
return Promise.resolve();
|
|
434
|
+
}) as never);
|
|
435
|
+
|
|
436
|
+
getJestSpyOn(
|
|
437
|
+
GoogleSecOpsConnectionService,
|
|
438
|
+
"updateOneById",
|
|
439
|
+
).mockImplementation(((call: ConnectionUpdateCall): Promise<void> => {
|
|
440
|
+
updates.push(call);
|
|
441
|
+
return Promise.resolve();
|
|
442
|
+
}) as never);
|
|
443
|
+
|
|
444
|
+
/*
|
|
445
|
+
* Enrichment reaches ClickHouse for its per-project indicator probe and
|
|
446
|
+
* swallows its own failures, so leaving it live would make every test
|
|
447
|
+
* here depend on a database being absent in exactly the right way.
|
|
448
|
+
*/
|
|
449
|
+
const enricherSpy: ReturnType<typeof getJestSpyOn> = getJestSpyOn(
|
|
450
|
+
ThreatIntelEnricher,
|
|
451
|
+
"enrichNormalizedEvents",
|
|
452
|
+
).mockResolvedValue({ eventsMatched: 0, valuesLookedUp: 0 } as never);
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
updates: updates,
|
|
456
|
+
insertedBatches: insertedBatches,
|
|
457
|
+
logs: captureLogs(),
|
|
458
|
+
telemetrySpy: telemetrySpy,
|
|
459
|
+
insertSpy: insertSpy,
|
|
460
|
+
enricherSpy: enricherSpy,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function onlyUpdate(harness: PollHarness): JSONObject {
|
|
465
|
+
expect(harness.updates).toHaveLength(1);
|
|
466
|
+
expect(harness.updates[0]!.id.toString()).toBe(CONNECTION_ID.toString());
|
|
467
|
+
|
|
468
|
+
return harness.updates[0]!.data;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function rowsByEventUid(rows: Array<JSONObject>): Map<string, JSONObject> {
|
|
472
|
+
const byUid: Map<string, JSONObject> = new Map<string, JSONObject>();
|
|
473
|
+
|
|
474
|
+
for (const row of rows) {
|
|
475
|
+
byUid.set(String(row["eventUid"]), row);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return byUid;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
describe("GoogleSecOpsPoller over a real streaming response", () => {
|
|
482
|
+
afterEach(() => {
|
|
483
|
+
jest.restoreAllMocks();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("a chunk-array body becomes Detection Finding rows, deduped across chunks", async () => {
|
|
487
|
+
const harness: PollHarness = stubPollPath();
|
|
488
|
+
|
|
489
|
+
/*
|
|
490
|
+
* Three chunks the way Chronicle streams them: alerts nested at
|
|
491
|
+
* chunk.alerts.alerts[], a later chunk restating an earlier alert, and
|
|
492
|
+
* a terminal chunk carrying only the completion flags.
|
|
493
|
+
*/
|
|
494
|
+
const { client } = makeClient(
|
|
495
|
+
streamingResponse([
|
|
496
|
+
{ progress: 0.25, alerts: { alerts: [alertOne()] } },
|
|
497
|
+
{ progress: 0.75, alerts: { alerts: [alertOne(), alertTwo()] } },
|
|
498
|
+
{
|
|
499
|
+
progress: 1,
|
|
500
|
+
complete: true,
|
|
501
|
+
baselineAlertsCount: 2,
|
|
502
|
+
filteredAlertsCount: 2,
|
|
503
|
+
},
|
|
504
|
+
]),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
508
|
+
makeConnection(),
|
|
509
|
+
client,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// Two alerts, not three: the restated alert-1 is one alert, not two.
|
|
513
|
+
expect(ingested).toBe(2);
|
|
514
|
+
expect(harness.insertedBatches).toHaveLength(1);
|
|
515
|
+
|
|
516
|
+
const rows: Array<JSONObject> = harness.insertedBatches[0]!;
|
|
517
|
+
expect(rows).toHaveLength(2);
|
|
518
|
+
|
|
519
|
+
const byUid: Map<string, JSONObject> = rowsByEventUid(rows);
|
|
520
|
+
expect(Array.from(byUid.keys()).sort()).toEqual(["alert-1", "alert-2"]);
|
|
521
|
+
|
|
522
|
+
const first: JSONObject = byUid.get("alert-1")!;
|
|
523
|
+
expect(first["classUid"]).toBe(DETECTION_FINDING_CLASS_UID);
|
|
524
|
+
expect(first["className"]).toBe("Detection Finding");
|
|
525
|
+
expect(first["vendorName"]).toBe("Google");
|
|
526
|
+
expect(first["productName"]).toBe("Google SecOps");
|
|
527
|
+
expect(first["projectId"]).toBe(PROJECT_ID.toString());
|
|
528
|
+
expect(first["ruleName"]).toBe("Suspicious PowerShell");
|
|
529
|
+
expect(first["ruleId"]).toBe("ru_1a2b");
|
|
530
|
+
expect(first["severityName"]).toBe("High");
|
|
531
|
+
expect(first["severityId"]).toBe(4);
|
|
532
|
+
expect(first["statusName"]).toBe("ALERTING");
|
|
533
|
+
|
|
534
|
+
// The matched UDM sample event is what puts entities on the finding.
|
|
535
|
+
expect(first["principalHost"]).toBe("workstation-14");
|
|
536
|
+
expect(first["principalIp"]).toBe("10.1.2.3");
|
|
537
|
+
expect(first["principalUser"]).toBe("jsmith");
|
|
538
|
+
expect(first["targetIp"]).toBe("203.0.113.9");
|
|
539
|
+
expect(first["observables"]).toEqual(
|
|
540
|
+
expect.arrayContaining(["jsmith", "workstation-14", "10.1.2.3"]),
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const second: JSONObject = byUid.get("alert-2")!;
|
|
544
|
+
expect(second["ruleName"]).toBe("Impossible travel");
|
|
545
|
+
expect(second["severityName"]).toBe("Critical");
|
|
546
|
+
expect(second["classUid"]).toBe(DETECTION_FINDING_CLASS_UID);
|
|
547
|
+
|
|
548
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
549
|
+
expect(Object.keys(written).sort()).toEqual([
|
|
550
|
+
"cursor",
|
|
551
|
+
"lastError",
|
|
552
|
+
"lastPolledAt",
|
|
553
|
+
]);
|
|
554
|
+
expect(written["lastError"]).toBeNull();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("the cursor written is exactly the endTime the fetch was made with", async () => {
|
|
558
|
+
const harness: PollHarness = stubPollPath();
|
|
559
|
+
|
|
560
|
+
const { client, requests } = makeClient(
|
|
561
|
+
streamingResponse([
|
|
562
|
+
{ alerts: { alerts: [alertOne()] } },
|
|
563
|
+
{ complete: true, progress: 1 },
|
|
564
|
+
]),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
await GoogleSecOpsPoller.pollConnection(makeConnection(), client);
|
|
568
|
+
|
|
569
|
+
const sent: { startTime: string; endTime: string } = windowSentTo(requests);
|
|
570
|
+
|
|
571
|
+
// The parameter really is an ISO instant, not some other rendering.
|
|
572
|
+
expect(new Date(sent.endTime).toISOString()).toBe(sent.endTime);
|
|
573
|
+
|
|
574
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
575
|
+
expect(written["cursor"]).toBe(sent.endTime);
|
|
576
|
+
expect((written["lastPolledAt"] as Date).toISOString()).toBe(sent.endTime);
|
|
577
|
+
|
|
578
|
+
// ...and the window it closes really did start earlier than it ends.
|
|
579
|
+
expect(new Date(sent.startTime).getTime()).toBeLessThan(
|
|
580
|
+
new Date(sent.endTime).getTime(),
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("a recognized response carrying no alerts advances the cursor and clears lastError", async () => {
|
|
585
|
+
const harness: PollHarness = stubPollPath();
|
|
586
|
+
|
|
587
|
+
// A quiet window: recognized chunk fields, zero alerts, complete.
|
|
588
|
+
const { client, requests } = makeClient(
|
|
589
|
+
streamingResponse([{ complete: true, progress: 1 }]),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
593
|
+
makeConnection(),
|
|
594
|
+
client,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
expect(ingested).toBe(0);
|
|
598
|
+
expect(harness.telemetrySpy).not.toHaveBeenCalled();
|
|
599
|
+
expect(harness.insertSpy).not.toHaveBeenCalled();
|
|
600
|
+
|
|
601
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
602
|
+
expect(Object.keys(written).sort()).toEqual([
|
|
603
|
+
"cursor",
|
|
604
|
+
"lastError",
|
|
605
|
+
"lastPolledAt",
|
|
606
|
+
]);
|
|
607
|
+
expect(written["cursor"]).toBe(windowSentTo(requests).endTime);
|
|
608
|
+
expect(written["lastError"]).toBeNull();
|
|
609
|
+
|
|
610
|
+
/*
|
|
611
|
+
* The count that separates this from the failure below has to be
|
|
612
|
+
* readable somewhere; "quiet" and "broken" looked identical for as
|
|
613
|
+
* long as it was not.
|
|
614
|
+
*/
|
|
615
|
+
const quietWindowLine: RegExp = /fetched 0 alerts and ingested 0/;
|
|
616
|
+
const counted: Array<string> = linesAboutConnection(
|
|
617
|
+
harness.logs.debug,
|
|
618
|
+
).filter((line: string): boolean => {
|
|
619
|
+
return quietWindowLine.test(line);
|
|
620
|
+
});
|
|
621
|
+
expect(counted).toHaveLength(1);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("truncation flags surface as warnings without failing the poll", async () => {
|
|
625
|
+
const harness: PollHarness = stubPollPath();
|
|
626
|
+
|
|
627
|
+
/*
|
|
628
|
+
* Chronicle capped the result server side. There is no pagination on
|
|
629
|
+
* this endpoint, so the dropped alerts are gone — the poll still
|
|
630
|
+
* succeeded, and the operator still has to be told.
|
|
631
|
+
*/
|
|
632
|
+
const { client, requests } = makeClient(
|
|
633
|
+
streamingResponse([
|
|
634
|
+
{
|
|
635
|
+
alerts: { alerts: [alertOne()] },
|
|
636
|
+
tooManyAlerts: true,
|
|
637
|
+
memoryLimitExceeded: true,
|
|
638
|
+
complete: true,
|
|
639
|
+
progress: 1,
|
|
640
|
+
},
|
|
641
|
+
]),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
645
|
+
makeConnection(),
|
|
646
|
+
client,
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
expect(ingested).toBe(1);
|
|
650
|
+
expect(harness.insertedBatches).toHaveLength(1);
|
|
651
|
+
expect(harness.insertedBatches[0]!).toHaveLength(1);
|
|
652
|
+
|
|
653
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
654
|
+
expect(written["cursor"]).toBe(windowSentTo(requests).endTime);
|
|
655
|
+
expect(written["lastError"]).toBeNull();
|
|
656
|
+
|
|
657
|
+
const warnings: Array<string> = linesAboutConnection(harness.logs.warn);
|
|
658
|
+
|
|
659
|
+
// Both truncations named, both against the window they happened in.
|
|
660
|
+
const truncatedByCountLine: RegExp = /no pagination/i;
|
|
661
|
+
const truncatedByBytesLine: RegExp = /memory limit/i;
|
|
662
|
+
|
|
663
|
+
const byCount: Array<string> = warnings.filter((line: string): boolean => {
|
|
664
|
+
return truncatedByCountLine.test(line);
|
|
665
|
+
});
|
|
666
|
+
const byBytes: Array<string> = warnings.filter((line: string): boolean => {
|
|
667
|
+
return truncatedByBytesLine.test(line);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
expect(byCount).toHaveLength(1);
|
|
671
|
+
expect(byBytes).toHaveLength(1);
|
|
672
|
+
expect(byCount[0]!).toContain(windowSentTo(requests).endTime);
|
|
673
|
+
expect(byBytes[0]!).toContain(windowSentTo(requests).endTime);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe("GoogleSecOpsPoller cursor durability", () => {
|
|
678
|
+
afterEach(() => {
|
|
679
|
+
jest.restoreAllMocks();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
/*
|
|
683
|
+
* THE PIN. This is the failure that has to stay impossible.
|
|
684
|
+
*
|
|
685
|
+
* A body the client cannot recognize must throw rather than report zero
|
|
686
|
+
* alerts, and the throw must leave the cursor exactly where it was. If
|
|
687
|
+
* either half regresses — the client returning [] for an unknown shape,
|
|
688
|
+
* or the poller advancing on the way past a failure — a connector that
|
|
689
|
+
* reads nothing at all becomes indistinguishable from a quiet one, and
|
|
690
|
+
* every alert in every skipped window is lost permanently.
|
|
691
|
+
*/
|
|
692
|
+
test("an unrecognized response body leaves the cursor untouched and records lastError", async () => {
|
|
693
|
+
const harness: PollHarness = stubPollPath();
|
|
694
|
+
|
|
695
|
+
const storedCursor: string = new Date(
|
|
696
|
+
Date.now() - 10 * MINUTE_IN_MS,
|
|
697
|
+
).toISOString();
|
|
698
|
+
|
|
699
|
+
const connection: GoogleSecOpsConnection = makeConnection();
|
|
700
|
+
connection.cursor = storedCursor;
|
|
701
|
+
|
|
702
|
+
// HTTP 200, valid JSON, and not this endpoint's response.
|
|
703
|
+
const { client } = makeClient({ status: 200, body: '{"nope":true}' });
|
|
704
|
+
|
|
705
|
+
getJestSpyOn(GoogleSecOpsConnectionService, "findBy").mockResolvedValue([
|
|
706
|
+
connection,
|
|
707
|
+
] as never);
|
|
708
|
+
|
|
709
|
+
getJestSpyOn(GoogleSecOpsPoller, "pollConnection").mockImplementation(((
|
|
710
|
+
polled: GoogleSecOpsConnection,
|
|
711
|
+
): Promise<number> => {
|
|
712
|
+
return originalPollConnection(polled, client);
|
|
713
|
+
}) as never);
|
|
714
|
+
|
|
715
|
+
await expect(
|
|
716
|
+
GoogleSecOpsPoller.pollAllDueConnections(),
|
|
717
|
+
).resolves.toBeUndefined();
|
|
718
|
+
|
|
719
|
+
expect(harness.insertSpy).not.toHaveBeenCalled();
|
|
720
|
+
|
|
721
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
722
|
+
|
|
723
|
+
/*
|
|
724
|
+
* No `cursor` key at all. Not "the same cursor" — the failure path
|
|
725
|
+
* must not be in the business of writing the column.
|
|
726
|
+
*/
|
|
727
|
+
expect(Object.keys(written).sort()).toEqual(["lastError", "lastPolledAt"]);
|
|
728
|
+
expect(written["lastPolledAt"]).toBeInstanceOf(Date);
|
|
729
|
+
expect(connection.cursor).toBe(storedCursor);
|
|
730
|
+
|
|
731
|
+
const lastError: unknown = written["lastError"];
|
|
732
|
+
expect(typeof lastError).toBe("string");
|
|
733
|
+
// The body is echoed, so the operator can see what came back.
|
|
734
|
+
expect(lastError as string).toContain('{"nope":true}');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("a fetch that throws before any alert arrives leaves the cursor untouched", async () => {
|
|
738
|
+
const harness: PollHarness = stubPollPath();
|
|
739
|
+
|
|
740
|
+
const connection: GoogleSecOpsConnection = makeConnection();
|
|
741
|
+
|
|
742
|
+
getJestSpyOn(GoogleSecOpsConnectionService, "findBy").mockResolvedValue([
|
|
743
|
+
connection,
|
|
744
|
+
] as never);
|
|
745
|
+
|
|
746
|
+
getJestSpyOn(GoogleSecOpsPoller, "pollConnection").mockImplementation(((
|
|
747
|
+
polled: GoogleSecOpsConnection,
|
|
748
|
+
): Promise<number> => {
|
|
749
|
+
return originalPollConnection(
|
|
750
|
+
polled,
|
|
751
|
+
makeThrowingStubClient(new Error("socket hang up")),
|
|
752
|
+
);
|
|
753
|
+
}) as never);
|
|
754
|
+
|
|
755
|
+
await GoogleSecOpsPoller.pollAllDueConnections();
|
|
756
|
+
|
|
757
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
758
|
+
expect(Object.keys(written).sort()).toEqual(["lastError", "lastPolledAt"]);
|
|
759
|
+
expect(written["lastError"]).toContain("socket hang up");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("alerts that all fail to normalize hold the cursor rather than skipping the window", async () => {
|
|
763
|
+
const harness: PollHarness = stubPollPath();
|
|
764
|
+
|
|
765
|
+
/*
|
|
766
|
+
* The normalizer is tolerant by design, so a poison alert has to be
|
|
767
|
+
* built out of a hostile getter: the alerts are real as far as
|
|
768
|
+
* Chronicle is concerned and unstorable as far as we are concerned,
|
|
769
|
+
* which is the case where re-fetching the window is the only way the
|
|
770
|
+
* data is ever recovered.
|
|
771
|
+
*/
|
|
772
|
+
const poison: JSONObject = {};
|
|
773
|
+
Object.defineProperty(poison, "detection", {
|
|
774
|
+
get: (): never => {
|
|
775
|
+
throw new Error("poison alert");
|
|
776
|
+
},
|
|
777
|
+
enumerable: true,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const alsoPoison: JSONObject = {};
|
|
781
|
+
Object.defineProperty(alsoPoison, "detection", {
|
|
782
|
+
get: (): never => {
|
|
783
|
+
throw new Error("poison alert");
|
|
784
|
+
},
|
|
785
|
+
enumerable: true,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const { client } = makeStubClient(stubbedFetchResult([poison, alsoPoison]));
|
|
789
|
+
|
|
790
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
791
|
+
makeConnection(),
|
|
792
|
+
client,
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
expect(ingested).toBe(0);
|
|
796
|
+
expect(harness.insertSpy).not.toHaveBeenCalled();
|
|
797
|
+
|
|
798
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
799
|
+
expect(Object.keys(written).sort()).toEqual(["lastError", "lastPolledAt"]);
|
|
800
|
+
|
|
801
|
+
const heldCursorLine: RegExp = /normalized none of its 2 fetched alerts/;
|
|
802
|
+
const held: Array<string> = linesAboutConnection(harness.logs.warn).filter(
|
|
803
|
+
(line: string): boolean => {
|
|
804
|
+
return heldCursorLine.test(line);
|
|
805
|
+
},
|
|
806
|
+
);
|
|
807
|
+
expect(held).toHaveLength(1);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
/*
|
|
811
|
+
* THE OTHER PIN. A FetchAlertsViewResponse chunk is not an alert.
|
|
812
|
+
*
|
|
813
|
+
* GoogleSecOpsAlertNormalizer.normalize is a total function whose class,
|
|
814
|
+
* category, vendor and product are constants, so ANY object handed to it
|
|
815
|
+
* comes back as a plausible Detection Finding: severity Unknown, no
|
|
816
|
+
* entities, message "Google SecOps detection". Stored, it is
|
|
817
|
+
* indistinguishable in the UI from a real low-signal detection. The gate
|
|
818
|
+
* in front of the normalizer is the only thing between an envelope chunk
|
|
819
|
+
* and a fabricated security finding.
|
|
820
|
+
*/
|
|
821
|
+
test("a stream chunk that reaches the poller produces no row", async () => {
|
|
822
|
+
const harness: PollHarness = stubPollPath();
|
|
823
|
+
|
|
824
|
+
/*
|
|
825
|
+
* What the gate stands in front of. Handed the chunk directly, the
|
|
826
|
+
* normalizer answers with a fully formed Detection Finding — this is
|
|
827
|
+
* the row the poll below must not write, and the reason "it produced
|
|
828
|
+
* no row" is a real property rather than an accident of the fixture.
|
|
829
|
+
*/
|
|
830
|
+
const wouldBeStored: NormalizedSecurityEvent =
|
|
831
|
+
GoogleSecOpsAlertNormalizer.normalize(streamChunk());
|
|
832
|
+
expect(wouldBeStored.classUid).toBe(DETECTION_FINDING_CLASS_UID);
|
|
833
|
+
expect(wouldBeStored.className).toBe("Detection Finding");
|
|
834
|
+
expect(wouldBeStored.severityName).toBe(OcsfSeverity.Unknown);
|
|
835
|
+
expect(wouldBeStored.message).toBe("Google SecOps detection");
|
|
836
|
+
expect(GoogleSecOpsAlertNormalizer.isGoogleSecOpsAlert(streamChunk())).toBe(
|
|
837
|
+
false,
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
const { client } = makeStubClient(
|
|
841
|
+
stubbedFetchResult([streamChunk(), alertOne()]),
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
845
|
+
makeConnection(),
|
|
846
|
+
client,
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
expect(ingested).toBe(1);
|
|
850
|
+
expect(harness.insertedBatches).toHaveLength(1);
|
|
851
|
+
|
|
852
|
+
const rows: Array<JSONObject> = harness.insertedBatches[0]!;
|
|
853
|
+
expect(rows).toHaveLength(1);
|
|
854
|
+
expect(rows[0]!["eventUid"]).toBe("alert-1");
|
|
855
|
+
|
|
856
|
+
/*
|
|
857
|
+
* The fabricated row's fingerprint, from the defect report: a
|
|
858
|
+
* content-hash eventUid, severity Unknown and the fallback message.
|
|
859
|
+
*/
|
|
860
|
+
for (const row of rows) {
|
|
861
|
+
expect(row["message"]).not.toBe("Google SecOps detection");
|
|
862
|
+
expect(String(row["eventUid"]).startsWith("sha256:")).toBe(false);
|
|
863
|
+
expect(row["severityName"]).not.toBe("Unknown");
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const discardedLine: RegExp = /discarded 1 of 2/;
|
|
867
|
+
const discarded: Array<string> = linesAboutConnection(
|
|
868
|
+
harness.logs.warn,
|
|
869
|
+
).filter((line: string): boolean => {
|
|
870
|
+
return discardedLine.test(line);
|
|
871
|
+
});
|
|
872
|
+
expect(discarded).toHaveLength(1);
|
|
873
|
+
|
|
874
|
+
/*
|
|
875
|
+
* A refusal is deterministic: re-fetching the window would refuse the
|
|
876
|
+
* same object again forever, so unlike a normalization failure it must
|
|
877
|
+
* not hold the cursor.
|
|
878
|
+
*/
|
|
879
|
+
const written: JSONObject = onlyUpdate(harness);
|
|
880
|
+
expect(Object.keys(written).sort()).toEqual([
|
|
881
|
+
"cursor",
|
|
882
|
+
"lastError",
|
|
883
|
+
"lastPolledAt",
|
|
884
|
+
]);
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
describe("GoogleSecOpsPoller ingest bookkeeping", () => {
|
|
889
|
+
afterEach(() => {
|
|
890
|
+
jest.restoreAllMocks();
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("the ingested count is returned and matches the rows written", async () => {
|
|
894
|
+
const harness: PollHarness = stubPollPath();
|
|
895
|
+
|
|
896
|
+
const { client } = makeStubClient(
|
|
897
|
+
stubbedFetchResult([alertOne(), alertTwo(), streamChunk()]),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
const ingested: number = await GoogleSecOpsPoller.pollConnection(
|
|
901
|
+
makeConnection(),
|
|
902
|
+
client,
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
expect(ingested).toBe(2);
|
|
906
|
+
expect(harness.insertedBatches[0]!).toHaveLength(ingested);
|
|
907
|
+
|
|
908
|
+
const countedLine: RegExp = /fetched 3 alerts and ingested 2/;
|
|
909
|
+
const counted: Array<string> = linesAboutConnection(
|
|
910
|
+
harness.logs.debug,
|
|
911
|
+
).filter((line: string): boolean => {
|
|
912
|
+
return countedLine.test(line);
|
|
913
|
+
});
|
|
914
|
+
expect(counted).toHaveLength(1);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("a multi-alert batch is written in a single insert", async () => {
|
|
918
|
+
const harness: PollHarness = stubPollPath();
|
|
919
|
+
|
|
920
|
+
const { client } = makeStubClient(
|
|
921
|
+
stubbedFetchResult([alertOne(), alertTwo()]),
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
await GoogleSecOpsPoller.pollConnection(makeConnection(), client);
|
|
925
|
+
|
|
926
|
+
expect(harness.insertSpy).toHaveBeenCalledTimes(1);
|
|
927
|
+
expect(harness.insertedBatches).toHaveLength(1);
|
|
928
|
+
expect(harness.insertedBatches[0]!).toHaveLength(2);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("threat intel is stamped on polled alerts before the rows are built", async () => {
|
|
932
|
+
const harness: PollHarness = stubPollPath();
|
|
933
|
+
|
|
934
|
+
const enrichCalls: Array<{
|
|
935
|
+
projectId: ObjectID;
|
|
936
|
+
events: Array<NormalizedSecurityEvent>;
|
|
937
|
+
}> = [];
|
|
938
|
+
|
|
939
|
+
/*
|
|
940
|
+
* Replacing the implementation on the spy that is already installed,
|
|
941
|
+
* rather than spying again — a second spy over the first would restore
|
|
942
|
+
* to the first mock instead of the real method.
|
|
943
|
+
*/
|
|
944
|
+
harness.enricherSpy.mockImplementation(((data: {
|
|
945
|
+
projectId: ObjectID;
|
|
946
|
+
events: Array<NormalizedSecurityEvent>;
|
|
947
|
+
}): Promise<EnrichmentResult> => {
|
|
948
|
+
enrichCalls.push(data);
|
|
949
|
+
|
|
950
|
+
for (const event of data.events) {
|
|
951
|
+
event.attributes["threat.matched"] = "true";
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return Promise.resolve({
|
|
955
|
+
eventsMatched: data.events.length,
|
|
956
|
+
valuesLookedUp: 1,
|
|
957
|
+
});
|
|
958
|
+
}) as never);
|
|
959
|
+
|
|
960
|
+
const { client } = makeStubClient(stubbedFetchResult([alertOne()]));
|
|
961
|
+
|
|
962
|
+
await GoogleSecOpsPoller.pollConnection(makeConnection(), client);
|
|
963
|
+
|
|
964
|
+
expect(enrichCalls).toHaveLength(1);
|
|
965
|
+
expect(enrichCalls[0]!.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
966
|
+
expect(enrichCalls[0]!.events).toHaveLength(1);
|
|
967
|
+
expect(enrichCalls[0]!.events[0]!.classUid).toBe(
|
|
968
|
+
DETECTION_FINDING_CLASS_UID,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
/*
|
|
972
|
+
* The stamp survives into the row, which is only true if enrichment
|
|
973
|
+
* runs ahead of buildSecurityEventDbRow — the same seam HTTP ingest
|
|
974
|
+
* uses, and the reason a stamped key is filterable at all.
|
|
975
|
+
*/
|
|
976
|
+
const row: JSONObject = harness.insertedBatches[0]![0]!;
|
|
977
|
+
const attributes: JSONObject = row["attributes"] as JSONObject;
|
|
978
|
+
expect(attributes["threat.matched"]).toBe("true");
|
|
979
|
+
expect(row["attributeKeys"] as Array<string>).toContain("threat.matched");
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
describe("GoogleSecOpsPoller poll window arithmetic", () => {
|
|
984
|
+
afterEach(() => {
|
|
985
|
+
jest.restoreAllMocks();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
/*
|
|
989
|
+
* Runs one poll and hands back the window it asked Chronicle for. The
|
|
990
|
+
* caller installs the stubs once; this only reads.
|
|
991
|
+
*/
|
|
992
|
+
async function pollWindow(
|
|
993
|
+
cursor: string | undefined,
|
|
994
|
+
): Promise<RecordedWindow> {
|
|
995
|
+
const connection: GoogleSecOpsConnection = makeConnection();
|
|
996
|
+
|
|
997
|
+
if (cursor !== undefined) {
|
|
998
|
+
connection.cursor = cursor;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const { client, windows } = makeStubClient(stubbedFetchResult([]));
|
|
1002
|
+
|
|
1003
|
+
await GoogleSecOpsPoller.pollConnection(connection, client);
|
|
1004
|
+
|
|
1005
|
+
expect(windows).toHaveLength(1);
|
|
1006
|
+
|
|
1007
|
+
return windows[0]!;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function durationInMs(window: RecordedWindow): number {
|
|
1011
|
+
return window.endTime.getTime() - window.startTime.getTime();
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
test("an unreadable cursor polls the default window, not the 24 hour maximum", async () => {
|
|
1015
|
+
stubPollPath();
|
|
1016
|
+
|
|
1017
|
+
/*
|
|
1018
|
+
* Derived rather than pasted: a first poll IS the default window and a
|
|
1019
|
+
* week-old cursor IS the maximum, so the two bounds move with the
|
|
1020
|
+
* constants instead of going stale against them.
|
|
1021
|
+
*/
|
|
1022
|
+
const firstPoll: RecordedWindow = await pollWindow(undefined);
|
|
1023
|
+
const stale: RecordedWindow = await pollWindow(
|
|
1024
|
+
new Date(Date.now() - 7 * DAY_IN_MS).toISOString(),
|
|
1025
|
+
);
|
|
1026
|
+
const garbage: RecordedWindow = await pollWindow("garbage");
|
|
1027
|
+
|
|
1028
|
+
expect(durationInMs(firstPoll)).toBe(15 * MINUTE_IN_MS);
|
|
1029
|
+
expect(durationInMs(stale)).toBe(DAY_IN_MS);
|
|
1030
|
+
|
|
1031
|
+
/*
|
|
1032
|
+
* An unreadable cursor means "no usable cursor", which is what a first
|
|
1033
|
+
* poll means. It used to share a branch with a stale cursor and open
|
|
1034
|
+
* the full 24 hours — the widest blast radius for the least
|
|
1035
|
+
* trustworthy input, re-ingesting a day as duplicates.
|
|
1036
|
+
*/
|
|
1037
|
+
expect(durationInMs(garbage)).toBe(durationInMs(firstPoll));
|
|
1038
|
+
expect(durationInMs(garbage)).not.toBe(durationInMs(stale));
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("an unreadable cursor is reported, quoting the value that could not be read", async () => {
|
|
1042
|
+
const harness: PollHarness = stubPollPath();
|
|
1043
|
+
|
|
1044
|
+
await pollWindow("2026-13-45T99:99:99Z");
|
|
1045
|
+
|
|
1046
|
+
const warnings: Array<string> = linesAboutConnection(
|
|
1047
|
+
harness.logs.warn,
|
|
1048
|
+
).filter((line: string): boolean => {
|
|
1049
|
+
return line.includes(JSON.stringify("2026-13-45T99:99:99Z"));
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
expect(warnings).toHaveLength(1);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("a cursor in the future never produces an inverted time range", async () => {
|
|
1056
|
+
const harness: PollHarness = stubPollPath();
|
|
1057
|
+
|
|
1058
|
+
const firstPoll: RecordedWindow = await pollWindow(undefined);
|
|
1059
|
+
|
|
1060
|
+
// Clock skew on the writer, or a restored backup.
|
|
1061
|
+
const future: RecordedWindow = await pollWindow(
|
|
1062
|
+
new Date(Date.now() + HOUR_IN_MS).toISOString(),
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
expect(future.startTime.getTime()).toBeLessThan(future.endTime.getTime());
|
|
1066
|
+
expect(durationInMs(future)).toBe(durationInMs(firstPoll));
|
|
1067
|
+
|
|
1068
|
+
expect(linesAboutConnection(harness.logs.warn).length).toBeGreaterThan(0);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test("a stale cursor is truncated to the cap and the skipped gap is stated", async () => {
|
|
1072
|
+
const harness: PollHarness = stubPollPath();
|
|
1073
|
+
|
|
1074
|
+
const stale: RecordedWindow = await pollWindow(
|
|
1075
|
+
new Date(Date.now() - 7 * DAY_IN_MS).toISOString(),
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
expect(durationInMs(stale)).toBe(DAY_IN_MS);
|
|
1079
|
+
|
|
1080
|
+
/*
|
|
1081
|
+
* The alerts between the cursor and the cap are skipped and will never
|
|
1082
|
+
* be fetched again. Truncating silently is how a connector comes back
|
|
1083
|
+
* from an outage looking healthy with a day of detections missing, so
|
|
1084
|
+
* the warning has to name the boundary the poll actually resumed from.
|
|
1085
|
+
*/
|
|
1086
|
+
const warnings: Array<string> = linesAboutConnection(
|
|
1087
|
+
harness.logs.warn,
|
|
1088
|
+
).filter((line: string): boolean => {
|
|
1089
|
+
return line.includes(stale.startTime.toISOString());
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
expect(warnings).toHaveLength(1);
|
|
1093
|
+
expect(warnings[0]!).toMatch(/skipped/i);
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
describe("GoogleSecOpsPoller.pollAllDueConnections scheduling", () => {
|
|
1098
|
+
afterEach(() => {
|
|
1099
|
+
jest.restoreAllMocks();
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
function makeDueConnection(id: string): GoogleSecOpsConnection {
|
|
1103
|
+
const connection: GoogleSecOpsConnection = makeConnection();
|
|
1104
|
+
connection._id = id;
|
|
1105
|
+
return connection;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function polledIds(spy: ReturnType<typeof getJestSpyOn>): Array<string> {
|
|
1109
|
+
return spy.mock.calls.map((call: Array<unknown>): string => {
|
|
1110
|
+
return String((call[0] as GoogleSecOpsConnection)._id);
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
test("an unusable pollIntervalInMinutes is clamped rather than making a connection always due", async () => {
|
|
1115
|
+
stubPollPath();
|
|
1116
|
+
|
|
1117
|
+
const justPolledAt: Date = new Date(Date.now() - 30 * 1000);
|
|
1118
|
+
|
|
1119
|
+
/*
|
|
1120
|
+
* Thirty seconds is the discriminating gap: every clamped interval is
|
|
1121
|
+
* at least a minute, so all three of these are still inside their
|
|
1122
|
+
* window. Unclamped, 0 makes the connection due the instant it was
|
|
1123
|
+
* polled and a negative interval puts its next poll ten minutes in the
|
|
1124
|
+
* past — both would hammer Chronicle every tick.
|
|
1125
|
+
*/
|
|
1126
|
+
const zero: GoogleSecOpsConnection = makeDueConnection(
|
|
1127
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
|
|
1128
|
+
);
|
|
1129
|
+
zero.pollIntervalInMinutes = 0;
|
|
1130
|
+
zero.lastPolledAt = justPolledAt;
|
|
1131
|
+
|
|
1132
|
+
const negative: GoogleSecOpsConnection = makeDueConnection(
|
|
1133
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
|
|
1134
|
+
);
|
|
1135
|
+
negative.pollIntervalInMinutes = -10;
|
|
1136
|
+
negative.lastPolledAt = justPolledAt;
|
|
1137
|
+
|
|
1138
|
+
const missing: GoogleSecOpsConnection = makeDueConnection(
|
|
1139
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
|
|
1140
|
+
);
|
|
1141
|
+
delete missing.pollIntervalInMinutes;
|
|
1142
|
+
missing.lastPolledAt = justPolledAt;
|
|
1143
|
+
|
|
1144
|
+
// The controls: one genuinely overdue, one never polled.
|
|
1145
|
+
const overdue: GoogleSecOpsConnection = makeDueConnection(
|
|
1146
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
|
|
1147
|
+
);
|
|
1148
|
+
overdue.pollIntervalInMinutes = 5;
|
|
1149
|
+
overdue.lastPolledAt = new Date(Date.now() - 10 * MINUTE_IN_MS);
|
|
1150
|
+
|
|
1151
|
+
const neverPolled: GoogleSecOpsConnection = makeDueConnection(
|
|
1152
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
|
|
1153
|
+
);
|
|
1154
|
+
delete neverPolled.pollIntervalInMinutes;
|
|
1155
|
+
|
|
1156
|
+
getJestSpyOn(GoogleSecOpsConnectionService, "findBy").mockResolvedValue([
|
|
1157
|
+
zero,
|
|
1158
|
+
negative,
|
|
1159
|
+
missing,
|
|
1160
|
+
overdue,
|
|
1161
|
+
neverPolled,
|
|
1162
|
+
] as never);
|
|
1163
|
+
|
|
1164
|
+
const pollSpy: ReturnType<typeof getJestSpyOn> = getJestSpyOn(
|
|
1165
|
+
GoogleSecOpsPoller,
|
|
1166
|
+
"pollConnection",
|
|
1167
|
+
).mockResolvedValue(0 as never);
|
|
1168
|
+
|
|
1169
|
+
await GoogleSecOpsPoller.pollAllDueConnections();
|
|
1170
|
+
|
|
1171
|
+
expect(polledIds(pollSpy)).toEqual([
|
|
1172
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
|
|
1173
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
|
|
1174
|
+
]);
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test("the ingested count of every polled connection is logged, not dropped", async () => {
|
|
1178
|
+
const harness: PollHarness = stubPollPath();
|
|
1179
|
+
|
|
1180
|
+
getJestSpyOn(GoogleSecOpsConnectionService, "findBy").mockResolvedValue([
|
|
1181
|
+
makeConnection(),
|
|
1182
|
+
] as never);
|
|
1183
|
+
|
|
1184
|
+
getJestSpyOn(GoogleSecOpsPoller, "pollConnection").mockResolvedValue(
|
|
1185
|
+
7 as never,
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
await GoogleSecOpsPoller.pollAllDueConnections();
|
|
1189
|
+
|
|
1190
|
+
/*
|
|
1191
|
+
* The one number that distinguishes "polling healthy but quiet" from
|
|
1192
|
+
* "polling silently broken". pollConnection returned it and the caller
|
|
1193
|
+
* used to throw it away.
|
|
1194
|
+
*/
|
|
1195
|
+
const reported: Array<number> = linesAboutConnection(harness.logs.info)
|
|
1196
|
+
.map((line: string): number => {
|
|
1197
|
+
const match: RegExpMatchArray | null = line.match(/ingested (\d+)/);
|
|
1198
|
+
return match ? Number(match[1]) : -1;
|
|
1199
|
+
})
|
|
1200
|
+
.filter((count: number): boolean => {
|
|
1201
|
+
return count >= 0;
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
expect(reported).toEqual([7]);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("only enabled connections are loaded, with every field the poll needs", async () => {
|
|
1208
|
+
stubPollPath();
|
|
1209
|
+
|
|
1210
|
+
const findBySpy: ReturnType<typeof getJestSpyOn> = getJestSpyOn(
|
|
1211
|
+
GoogleSecOpsConnectionService,
|
|
1212
|
+
"findBy",
|
|
1213
|
+
).mockResolvedValue([] as never);
|
|
1214
|
+
|
|
1215
|
+
await GoogleSecOpsPoller.pollAllDueConnections();
|
|
1216
|
+
|
|
1217
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
1218
|
+
|
|
1219
|
+
const args: JSONObject = findBySpy.mock.calls[0]![0] as JSONObject;
|
|
1220
|
+
|
|
1221
|
+
expect(args["query"]).toEqual({ isEnabled: true });
|
|
1222
|
+
expect(args["skip"]).toBe(0);
|
|
1223
|
+
expect(args["limit"]).toBe(LIMIT_MAX);
|
|
1224
|
+
expect(args["props"]).toEqual({ isRoot: true });
|
|
1225
|
+
|
|
1226
|
+
/*
|
|
1227
|
+
* Every column pollConnection reads. A field dropped here fails the
|
|
1228
|
+
* connection's own guard at poll time, which reads as a broken
|
|
1229
|
+
* credential rather than a missing select.
|
|
1230
|
+
*/
|
|
1231
|
+
const select: JSONObject = args["select"] as JSONObject;
|
|
1232
|
+
|
|
1233
|
+
for (const field of [
|
|
1234
|
+
"_id",
|
|
1235
|
+
"projectId",
|
|
1236
|
+
"region",
|
|
1237
|
+
"instanceResourceName",
|
|
1238
|
+
"serviceAccountJson",
|
|
1239
|
+
"pollIntervalInMinutes",
|
|
1240
|
+
"lastPolledAt",
|
|
1241
|
+
"cursor",
|
|
1242
|
+
]) {
|
|
1243
|
+
expect(select[field]).toBe(true);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
});
|