Skip to content

Commit baa017f

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/datadog-depth
# Conflicts: # apps/sim/tools/generated/tool-ids.ts # apps/sim/tools/generated/tool-metadata.ts # apps/sim/tools/generated/tool-outputs.ts
2 parents 273b165 + 4bc89c9 commit baa017f

40 files changed

Lines changed: 8979 additions & 352 deletions

apps/docs/content/docs/en/integrations/crowdstrike.mdx

Lines changed: 816 additions & 12 deletions
Large diffs are not rendered by default.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
import type { CrowdStrikeBaseParams, CrowdStrikeCloud } from '@/tools/crowdstrike/types'
3+
4+
export type JsonRecord = Record<string, unknown>
5+
6+
const CLOUD_BASE_URLS: Record<CrowdStrikeCloud, string> = {
7+
'eu-1': 'https://api.eu-1.crowdstrike.com',
8+
'us-1': 'https://api.crowdstrike.com',
9+
'us-2': 'https://api.us-2.crowdstrike.com',
10+
'us-3': 'https://api.us-3.crowdstrike.com',
11+
'us-gov-1': 'https://api.laggar.gcw.crowdstrike.com',
12+
'us-gov-2': 'https://api.us-gov-2.crowdstrike.mil',
13+
}
14+
15+
export function getCloudBaseUrl(cloud: CrowdStrikeCloud): string {
16+
return CLOUD_BASE_URLS[cloud]
17+
}
18+
19+
export function getString(value: unknown): string | null {
20+
return typeof value === 'string' ? value : null
21+
}
22+
23+
export function getNumber(value: unknown): number | null {
24+
return typeof value === 'number' ? value : null
25+
}
26+
27+
export function getBoolean(value: unknown): boolean | null {
28+
return typeof value === 'boolean' ? value : null
29+
}
30+
31+
export function getStringArray(value: unknown): string[] {
32+
if (!Array.isArray(value)) {
33+
return []
34+
}
35+
36+
return value.filter((entry): entry is string => typeof entry === 'string')
37+
}
38+
39+
export function getRecordArray(value: unknown): JsonRecord[] {
40+
if (!Array.isArray(value)) {
41+
return []
42+
}
43+
44+
return value.filter(isRecordLike)
45+
}
46+
47+
export function getRecord(value: unknown): JsonRecord | null {
48+
return isRecordLike(value) ? value : null
49+
}
50+
51+
/**
52+
* Every Falcon endpoint this integration calls answers with a flat
53+
* `{ meta, resources, errors }` envelope, so the envelope readers below and
54+
* `getFalconErrorMessage` both read the payload root directly.
55+
*/
56+
export function getResourcesArray(data: unknown): unknown[] {
57+
if (!isRecordLike(data) || !Array.isArray(data.resources)) {
58+
return []
59+
}
60+
61+
return data.resources
62+
}
63+
64+
export function getRecordResources(data: unknown): JsonRecord[] {
65+
return getResourcesArray(data).filter(isRecordLike)
66+
}
67+
68+
export function getStringResources(data: unknown): string[] {
69+
return getStringArray(getResourcesArray(data))
70+
}
71+
72+
export function getFirstRecordResource(data: unknown): JsonRecord | null {
73+
return getRecordResources(data)[0] ?? null
74+
}
75+
76+
export function getPagination(data: unknown) {
77+
if (!isRecordLike(data) || !isRecordLike(data.meta) || !isRecordLike(data.meta.pagination)) {
78+
return null
79+
}
80+
81+
const { pagination } = data.meta
82+
83+
return {
84+
limit: getNumber(pagination.limit),
85+
offset: getNumber(pagination.offset),
86+
total: getNumber(pagination.total),
87+
}
88+
}
89+
90+
/** Offset pagination plus the `after` cursor the IOC Management API returns. */
91+
export function getCursorPagination(data: unknown) {
92+
if (!isRecordLike(data) || !isRecordLike(data.meta) || !isRecordLike(data.meta.pagination)) {
93+
return null
94+
}
95+
96+
const { pagination } = data.meta
97+
98+
return {
99+
after: getString(pagination.after),
100+
limit: getNumber(pagination.limit),
101+
offset: getNumber(pagination.offset),
102+
total: getNumber(pagination.total),
103+
}
104+
}
105+
106+
/** Spotlight paginates by cursor only — it returns no offset. */
107+
export function getSpotlightPagination(data: unknown) {
108+
if (!isRecordLike(data) || !isRecordLike(data.meta) || !isRecordLike(data.meta.pagination)) {
109+
return null
110+
}
111+
112+
const { pagination } = data.meta
113+
114+
return {
115+
after: getString(pagination.after),
116+
limit: getNumber(pagination.limit),
117+
total: getNumber(pagination.total),
118+
}
119+
}
120+
121+
/**
122+
* CrowdStrike returns `{ meta, resources, errors }` on every endpoint, and a 200
123+
* can still carry a populated `errors` array for the IDs that failed.
124+
*/
125+
export function getEnvelopeErrors(data: unknown) {
126+
if (!isRecordLike(data)) {
127+
return []
128+
}
129+
130+
return getRecordArray(data.errors).map((entry) => ({
131+
code: getNumber(entry.code),
132+
id: getString(entry.id),
133+
message: getString(entry.message),
134+
}))
135+
}
136+
137+
export function getFalconErrorMessage(data: unknown, fallback: string): string {
138+
if (!isRecordLike(data)) {
139+
return fallback
140+
}
141+
142+
const errors = Array.isArray(data.errors) ? data.errors : []
143+
const firstError = errors[0]
144+
if (isRecordLike(firstError)) {
145+
const firstMessage = getString(firstError.message) ?? getString(firstError.code)
146+
if (firstMessage) {
147+
return firstMessage
148+
}
149+
}
150+
151+
return (
152+
getString(data.message) ??
153+
getString(data.error_description) ??
154+
getString(data.error) ??
155+
fallback
156+
)
157+
}
158+
159+
export async function getAccessToken(params: CrowdStrikeBaseParams): Promise<string> {
160+
const baseUrl = getCloudBaseUrl(params.cloud)
161+
const response = await fetch(`${baseUrl}/oauth2/token`, {
162+
method: 'POST',
163+
headers: {
164+
Accept: 'application/json',
165+
'Content-Type': 'application/x-www-form-urlencoded',
166+
},
167+
body: new URLSearchParams({
168+
client_id: params.clientId,
169+
client_secret: params.clientSecret,
170+
grant_type: 'client_credentials',
171+
}).toString(),
172+
cache: 'no-store',
173+
})
174+
175+
const data: unknown = await response.json().catch(() => null)
176+
if (!response.ok) {
177+
throw new Error(getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'))
178+
}
179+
180+
if (!isRecordLike(data) || typeof data.access_token !== 'string') {
181+
throw new Error('CrowdStrike authentication did not return an access token')
182+
}
183+
184+
return data.access_token
185+
}
186+
187+
interface CrowdStrikeRequestOptions {
188+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
189+
path: string
190+
query?: Record<string, string | number | boolean | undefined>
191+
repeatedQuery?: Record<string, string[] | undefined>
192+
body?: unknown
193+
}
194+
195+
export interface CrowdStrikeCallResult {
196+
ok: boolean
197+
status: number
198+
data: unknown
199+
}
200+
201+
export function buildUrl(baseUrl: string, options: CrowdStrikeRequestOptions): string {
202+
const url = new URL(options.path, baseUrl)
203+
204+
for (const [key, value] of Object.entries(options.query ?? {})) {
205+
if (value !== undefined) {
206+
url.searchParams.set(key, String(value))
207+
}
208+
}
209+
210+
for (const [key, values] of Object.entries(options.repeatedQuery ?? {})) {
211+
for (const value of values ?? []) {
212+
url.searchParams.append(key, value)
213+
}
214+
}
215+
216+
return url.toString()
217+
}
218+
219+
export async function callCrowdStrike(
220+
baseUrl: string,
221+
accessToken: string,
222+
options: CrowdStrikeRequestOptions
223+
): Promise<CrowdStrikeCallResult> {
224+
const headers: Record<string, string> = {
225+
Accept: 'application/json',
226+
Authorization: `Bearer ${accessToken}`,
227+
}
228+
229+
if (options.body !== undefined) {
230+
headers['Content-Type'] = 'application/json'
231+
}
232+
233+
const response = await fetch(buildUrl(baseUrl, options), {
234+
method: options.method,
235+
headers,
236+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
237+
cache: 'no-store',
238+
})
239+
240+
const data: unknown = await response.json().catch(() => null)
241+
242+
return { ok: response.ok, status: response.status, data }
243+
}

0 commit comments

Comments
 (0)