Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/docs/content/docs/en/integrations/dynatrace.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Get the full details of a single Dynatrace problem, including root cause, affect
| `environmentUrl` | string | Yes | Dynatrace environment URL \(e.g., https://abc12345.live.dynatrace.com, or https://your-activegate:9999/e/abc12345 for Managed\) |
| `apiToken` | string | Yes | Dynatrace access token \(dt0c01...\) with the problems.read scope |
| `problemId` | string | Yes | ID of the problem \(e.g., -1234567890123456789_1700000000000V2\) |
| `fields` | string | No | Comma-separated optional properties to include: evidenceDetails, impactAnalysis, recentComments |
| `fields` | string | No | Comma-separated optional properties to include. Defaults to all of them: evidenceDetails, impactAnalysis, recentComments |

#### Output

Expand Down Expand Up @@ -581,7 +581,7 @@ Get a single vulnerability with its description, remediation guidance, affected
| `environmentUrl` | string | Yes | Dynatrace environment URL \(e.g., https://abc12345.live.dynatrace.com, or https://your-activegate:9999/e/abc12345 for Managed\) |
| `apiToken` | string | Yes | Dynatrace access token \(dt0c01...\) with the securityProblems.read scope |
| `securityProblemId` | string | Yes | ID of the security problem |
| `fields` | string | No | Comma-separated optional properties to include: +riskAssessment, +managementZones, +codeLevelVulnerabilityDetails, +globalCounts |
| `fields` | string | No | Comma-separated optional properties to include, each prefixed with +. Defaults to every detail property: +riskAssessment, +managementZones, +codeLevelVulnerabilityDetails, +globalCounts, +filteredCounts, +description, +remediationDescription, +events, +vulnerableComponents, +affectedEntities, +exposedEntities, +reachableDataAssets, +relatedEntities, +relatedContainerImages, +relatedAttacks, +entryPoints |
| `managementZoneFilter` | string | No | Restrict the counts to management zones, e.g. names\("Production"\) |
| `from` | string | No | Start of the timeframe as UTC milliseconds, ISO 8601, or a relative expression such as now-24h. Defaults to the last 24 hours |

Expand Down Expand Up @@ -772,7 +772,7 @@ Get a single attack with its entry point, payload, attacker, and the vulnerabili
| `environmentUrl` | string | Yes | Dynatrace environment URL \(e.g., https://abc12345.live.dynatrace.com, or https://your-activegate:9999/e/abc12345 for Managed\) |
| `apiToken` | string | Yes | Dynatrace access token \(dt0c01...\) with the attacks.read scope |
| `attackId` | string | Yes | ID of the attack |
| `fields` | string | No | Comma-separated optional properties to include: +attackTarget, +request, +entrypoint, +vulnerability, +securityProblem, +attacker, +managementZones |
| `fields` | string | No | Comma-separated optional properties to include, each prefixed with +. Defaults to all of them: +attackTarget, +request, +entrypoint, +vulnerability, +securityProblem, +attacker, +managementZones |

#### Output

Expand Down
53 changes: 46 additions & 7 deletions apps/sim/blocks/blocks/dynatrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ const MUTE_OPERATIONS = [
'dynatrace_unmute_security_problems',
]

/**
* Operations that mute. Unmuting is deliberately excluded: Dynatrace accepts
* exactly one unmute reason (`AFFECTED`), so the block sends it itself rather
* than offering a choice that would only ever be wrong.
*/
const MUTE_ONLY_OPERATIONS = ['dynatrace_mute_security_problem', 'dynatrace_mute_security_problems']

/** The only `reason` the Dynatrace unmute endpoints accept. */
const UNMUTE_REASON = 'AFFECTED'

/** Operations that take the full SLO definition. */
const SLO_WRITE_OPERATIONS = ['dynatrace_create_slo', 'dynatrace_update_slo']

Expand Down Expand Up @@ -826,10 +836,9 @@ Return ONLY the selector string - no explanations, no surrounding quotes.`,
{ label: 'Vulnerable code not in use', id: 'VULNERABLE_CODE_NOT_IN_USE' },
{ label: 'Ignore', id: 'IGNORE' },
{ label: 'Other', id: 'OTHER' },
{ label: 'Affected (unmute only)', id: 'AFFECTED' },
],
required: true,
condition: { field: 'operation', value: MUTE_OPERATIONS },
condition: { field: 'operation', value: MUTE_ONLY_OPERATIONS },
value: () => 'FALSE_POSITIVE',
},
{
Expand Down Expand Up @@ -1398,6 +1407,18 @@ Return ONLY the selector string - no explanations, no surrounding quotes.`,
const toNumber = (value: unknown) =>
value === undefined || value === null || value === '' ? undefined : Number(value)

/**
* Reads a tri-state filter whose "any" position must send no parameter
* at all. The dropdown yields `''`, `'true'`, or `'false'`, but a real
* boolean arrives when the field is wired from an upstream block.
*/
const toOptionalBoolean = (value: unknown) => {
if (typeof value === 'boolean') return value
if (value === 'true') return true
if (value === 'false') return false
return undefined
}

const pagination = {
pageSize: toNumber(params.pageSize),
nextPageKey: params.nextPageKey || undefined,
Expand Down Expand Up @@ -1598,23 +1619,37 @@ Return ONLY the selector string - no explanations, no surrounding quotes.`,
}

case 'dynatrace_mute_security_problem':
case 'dynatrace_unmute_security_problem':
return {
...baseParams,
securityProblemId: params.securityProblemId,
reason: params.muteReason,
comment: params.muteComment || undefined,
}

case 'dynatrace_unmute_security_problem':
return {
...baseParams,
securityProblemId: params.securityProblemId,
reason: UNMUTE_REASON,
comment: params.muteComment || undefined,
}

case 'dynatrace_mute_security_problems':
case 'dynatrace_unmute_security_problems':
return {
...baseParams,
securityProblemIds: params.securityProblemIds,
reason: params.muteReason,
comment: params.muteComment || undefined,
}

case 'dynatrace_unmute_security_problems':
return {
...baseParams,
securityProblemIds: params.securityProblemIds,
reason: UNMUTE_REASON,
comment: params.muteComment || undefined,
}

case 'dynatrace_list_remediation_items':
return {
...baseParams,
Expand Down Expand Up @@ -1718,8 +1753,9 @@ Return ONLY the selector string - no explanations, no surrounding quotes.`,
return {
...baseParams,
type: params.monitorType || undefined,
// '' means "any", so send no filter rather than enabled=false.
enabled: params.monitorEnabled ? params.monitorEnabled === 'true' : undefined,
// "Any" must send no filter — enabled=false would return only the
// disabled monitors, the opposite of what was asked for.
enabled: toOptionalBoolean(params.monitorEnabled),
location: params.monitorLocation || undefined,
tag: params.monitorTag || undefined,
managementZone: toNumber(params.monitorManagementZone),
Expand Down Expand Up @@ -1859,7 +1895,10 @@ Return ONLY the selector string - no explanations, no surrounding quotes.`,
updateToken: { type: 'string', description: 'Optimistic-concurrency token' },
validateOnly: { type: 'boolean', description: 'Validate without saving' },
monitorType: { type: 'string', description: 'Synthetic monitor type filter' },
monitorEnabled: { type: 'boolean', description: 'Only enabled synthetic monitors' },
monitorEnabled: {
type: 'string',
description: 'Synthetic enabled filter: empty for any, "true", or "false"',
},
monitorLocation: { type: 'string', description: 'Synthetic location filter' },
monitorTag: { type: 'string', description: 'Synthetic monitor tag filter' },
monitorManagementZone: { type: 'number', description: 'Synthetic management zone ID' },
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/tools/dynatrace/create_settings_object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export const createSettingsObjectTool: ToolConfig<
const entries = Array.isArray(parsed) ? (parsed as Array<Record<string, unknown>>) : []
const results = entries.map(mapSettingsWriteResult)

// A rejected object comes back as 207 with a per-object 4xx, so the HTTP
// status alone would report a failed create as a success.
const failure = results.find((result) => result.code !== null && result.code >= 400)
if (failure) {
const message = (failure.writeError?.message as string) ?? `HTTP ${failure.code}`
throw new Error(`Dynatrace rejected the settings object: ${message}`)
}

return {
success: true,
output: {
Expand Down
127 changes: 125 additions & 2 deletions apps/sim/tools/dynatrace/dynatrace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { addTagsTool } from '@/tools/dynatrace/add_tags'
import { closeProblemTool } from '@/tools/dynatrace/close_problem'
import { createSettingsObjectTool } from '@/tools/dynatrace/create_settings_object'
import { createSloTool } from '@/tools/dynatrace/create_slo'
import { getAttackTool } from '@/tools/dynatrace/get_attack'
import { getAuditLogsTool } from '@/tools/dynatrace/get_audit_logs'
import { getEntityTool } from '@/tools/dynatrace/get_entity'
import { getMetricTool } from '@/tools/dynatrace/get_metric'
import { getProblemTool } from '@/tools/dynatrace/get_problem'
import { getSecurityProblemTool } from '@/tools/dynatrace/get_security_problem'
import { getSloTool } from '@/tools/dynatrace/get_slo'
import { getSyntheticBatchTool } from '@/tools/dynatrace/get_synthetic_batch'
import { ingestEventTool } from '@/tools/dynatrace/ingest_event'
Expand Down Expand Up @@ -91,8 +93,8 @@ describe('path identifiers', () => {
const base = { environmentUrl: ENV, apiToken: TOKEN }

it('trims whitespace pasted around an identifier', () => {
expect(url(getProblemTool, { ...base, problemId: ' P-123_456V2 ' })).toBe(
`${ENV}/api/v2/problems/P-123_456V2`
expect(new URL(url(getProblemTool, { ...base, problemId: ' P-123_456V2 ' })).pathname).toBe(
'/api/v2/problems/P-123_456V2'
)
expect(url(getEntityTool, { ...base, entityId: ' HOST-06F288EE2A930951\n' })).toBe(
`${ENV}/api/v2/entities/HOST-06F288EE2A930951`
Expand Down Expand Up @@ -148,6 +150,11 @@ describe('new-surface request shaping', () => {
expect(call('true').enabled).toBe(true)
expect(call('false').enabled).toBe(false)

// A value wired from an upstream block arrives as a real boolean, which must
// not read as "disabled only" the way `true === 'true'` would.
expect(call(true as unknown as string).enabled).toBe(true)
expect(call(false as unknown as string).enabled).toBe(false)

expect(url(listSyntheticMonitorsTool, { environmentUrl: ENV, apiToken: TOKEN })).toBe(
`${ENV}/api/v1/synthetic/monitors`
)
Expand Down Expand Up @@ -704,4 +711,120 @@ describe('response mapping', () => {
expect(result.output.accepted).toBe(false)
expect(result.output.details).toEqual({ error: { message: 'some invalid' } })
})

it('fails a settings write that Dynatrace rejected per-object under a 2xx', async () => {
const rejected = JSON.stringify([
{ code: 400, error: { code: 400, message: 'value.enabled is required' } },
])

await expect(
createSettingsObjectTool.transformResponse!(new Response(rejected, { status: 207 }))
).rejects.toThrow(/value.enabled is required/)

await expect(
updateSettingsObjectTool.transformResponse!(
new Response(JSON.stringify({ code: 400, error: { message: 'schema mismatch' } }), {
status: 207,
})
)
).rejects.toThrow(/schema mismatch/)
})
})

describe('detail requests ask for the properties they map', () => {
const base = { environmentUrl: ENV, apiToken: TOKEN }

it('requests every optional vulnerability property by default', () => {
// Dynatrace omits description, remediation guidance, and affected entities
// unless they are named in `fields`, so an unset default would map nulls.
const requested = new URL(
url(getSecurityProblemTool, { ...base, securityProblemId: 'S-1' })
).searchParams
.get('fields')
?.split(',')

expect(requested).toEqual(
expect.arrayContaining([
'+description',
'+remediationDescription',
'+affectedEntities',
'+vulnerableComponents',
'+riskAssessment',
])
)

// An explicit choice still wins.
expect(
new URL(
url(getSecurityProblemTool, {
...base,
securityProblemId: 'S-1',
fields: '+riskAssessment',
})
).searchParams.get('fields')
).toBe('+riskAssessment')
})

it('requests every optional attack property by default', () => {
expect(
new URL(url(getAttackTool, { ...base, attackId: 'A-1' })).searchParams.get('fields')
).toBe(
'+attackTarget,+request,+entrypoint,+vulnerability,+securityProblem,+attacker,+managementZones'
)

expect(
new URL(
url(getAttackTool, { ...base, attackId: 'A-1', fields: '+attacker' })
).searchParams.get('fields')
).toBe('+attacker')
})

it('requests the optional problem properties by default', () => {
expect(
new URL(url(getProblemTool, { ...base, problemId: 'P-1' })).searchParams.get('fields')
).toBe('evidenceDetails,impactAnalysis,recentComments')

expect(
new URL(
url(getProblemTool, { ...base, problemId: 'P-1', fields: 'impactAnalysis' })
).searchParams.get('fields')
).toBe('impactAnalysis')
})
})

describe('mute state writes', () => {
const params = (DynatraceBlock.tools.config?.params ?? (() => ({}))) as (
p: Record<string, unknown>
) => Record<string, unknown>

const call = (operation: string) =>
params({
operation,
environmentUrl: ENV,
apiToken: TOKEN,
securityProblemId: 'S-1',
securityProblemIds: 'S-1, S-2',
// The shared dropdown's default. It is a valid mute reason and an invalid
// unmute one, so forwarding it would make every unmute fail.
muteReason: 'FALSE_POSITIVE',
})

it('sends AFFECTED for an unmute, the only reason the API accepts', () => {
expect(call('dynatrace_unmute_security_problem').reason).toBe('AFFECTED')
expect(call('dynatrace_unmute_security_problems').reason).toBe('AFFECTED')
})

it('still forwards the chosen reason for a mute', () => {
expect(call('dynatrace_mute_security_problem').reason).toBe('FALSE_POSITIVE')
expect(call('dynatrace_mute_security_problems').reason).toBe('FALSE_POSITIVE')
})

it('offers only mute reasons in the dropdown, and only to the mute operations', () => {
const reason = DynatraceBlock.subBlocks.find((sb) => sb.id === 'muteReason')
expect(reason?.options).not.toContainEqual(expect.objectContaining({ id: 'AFFECTED' }))
expect(reason?.condition).toEqual({
field: 'operation',
value: ['dynatrace_mute_security_problem', 'dynatrace_mute_security_problems'],
})
})
})
19 changes: 17 additions & 2 deletions apps/sim/tools/dynatrace/get_attack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import {
import { ErrorExtractorId } from '@/tools/error-extractors'
import type { ToolConfig } from '@/tools/types'

/**
* Every optional property of the attack detail endpoint. Dynatrace omits them
* unless they are requested, so without this default the entry point, payload,
* attacker, and exploited vulnerability the tool maps would all be null.
*/
const ATTACK_DETAIL_FIELDS = [
'+attackTarget',
'+request',
'+entrypoint',
'+vulnerability',
'+securityProblem',
'+attacker',
'+managementZones',
].join(',')

export const getAttackTool: ToolConfig<DynatraceGetAttackParams, DynatraceGetAttackResponse> = {
id: 'dynatrace_get_attack',
name: 'Dynatrace Get Attack',
Expand Down Expand Up @@ -43,14 +58,14 @@ export const getAttackTool: ToolConfig<DynatraceGetAttackParams, DynatraceGetAtt
required: false,
visibility: 'user-or-llm',
description:
'Comma-separated optional properties to include: +attackTarget, +request, +entrypoint, +vulnerability, +securityProblem, +attacker, +managementZones',
'Comma-separated optional properties to include, each prefixed with +. Defaults to all of them: +attackTarget, +request, +entrypoint, +vulnerability, +securityProblem, +attacker, +managementZones',
},
},

request: {
url: (params) =>
buildDynatraceUrl(params.environmentUrl, `/attacks/${encodeDynatraceId(params.attackId)}`, {
fields: params.fields,
fields: params.fields || ATTACK_DETAIL_FIELDS,
}),
method: 'GET',
headers: (params) => dynatraceHeaders(params.apiToken),
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/tools/dynatrace/get_audit_logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,19 @@ export const getAuditLogsTool: ToolConfig<
sort: {
type: 'string',
required: false,
visibility: 'user-only',
visibility: 'user-or-llm',
description: 'timestamp for oldest first, or -timestamp for newest first (default)',
},
pageSize: {
type: 'number',
required: false,
visibility: 'user-only',
visibility: 'user-or-llm',
description: 'Entries per page (max 5000, default 1000)',
},
nextPageKey: {
type: 'string',
required: false,
visibility: 'user-only',
visibility: 'user-or-llm',
description: 'Cursor for the next page. All other filters are ignored when it is set',
},
},
Expand Down
Loading
Loading