Skip to content

Commit

Permalink
Merge branch 'main' into staging
Browse files Browse the repository at this point in the history
  • Loading branch information
daryllimyt committed Jan 28, 2025
2 parents 245a67b + d9fba38 commit da52f19
Show file tree
Hide file tree
Showing 14 changed files with 225 additions and 132 deletions.
10 changes: 2 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Coming soon.

## Community

Have questions? Feedback? New integration ideas for the project? Join the [Tracecat Community Discord](https://discord.gg/H4XZwsYzY4) and come hang out with us.
Have questions? Feedback? New integration ideas? Come hang out with us in the [Tracecat Community Discord](https://discord.gg/H4XZwsYzY4).

## Tracecat Registry

Expand All @@ -78,13 +78,7 @@ Have questions? Feedback? New integration ideas for the project? Join the [Trace
Tracecat Registry is a collection of integration and response-as-code templates.
Response actions are organized into [MITRE D3FEND](https://d3fend.mitre.org/) categories (`detect`, `isolate`, `evict`, `restore`, `harden`, `model`) and Tracecat's own ontology of capabilities (e.g. `list_alerts`, `list_cases`, `list_users`). Template inputs (e.g. `start_time`, `end_time`) are normalized to fit the [Open Cyber Security Schema (OCSF)](https://schema.ocsf.io/) ontology where possible.

Having thousands of out-of-the-box playbooks is an outdated and unsustainable approach to response automation.
Playbooks are rigid, hard to maintain, and don't scale.

We **strongly** believe in a community-driven ontology for all out-of-the-box response actions.
A common ontology serves as reusable building blocks for response workflows.

The future of response automation should be self-serve and reusable, where teams link pre-defined capabilities (e.g. `list_alerts` -> `enrich_ip_address` -> `block_ip_address`) into customizable workflows.
The future of response automation should be self-serve, where teams rapidly link common capabilities (e.g. `list_alerts` -> `enrich_ip_address` -> `block_ip_address`) into workflows.

**Examples**

Expand Down
99 changes: 99 additions & 0 deletions alembic/versions/4e07917dc4e8_keep_action_inputs_as_yaml_string.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Keep Action inputs as yaml string
Revision ID: 4e07917dc4e8
Revises: f92c80ef8c9d
Create Date: 2025-01-28 16:02:49.388047
"""

import json
from collections.abc import Sequence

import sqlalchemy as sa
import sqlmodel.sql.sqltypes
import yaml
from sqlalchemy import text
from sqlalchemy.dialects import postgresql

from alembic import op
from tracecat.logger import logger

# revision identifiers, used by Alembic.
revision: str = "4e07917dc4e8"
down_revision: str | None = "f92c80ef8c9d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# First alter the column type to text
op.alter_column(
"action",
"inputs",
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
)

# Then get all existing action inputs
connection = op.get_bind()
actions = connection.execute(text("SELECT id, inputs FROM action")).fetchall()

# Convert JSONB to YAML strings
for action_id, inputs in actions:
# JSONB data is a string. Load it as a dict.
# If its an empty dict, coerce it to None (empty string in yaml)
data = json.loads(inputs) or None
# Convert the dict to a YAML string.
yaml_str = yaml.dump(data) if data is not None else ""
logger.info(
"Updating action with inputs:",
inputs=inputs,
type=type(inputs).__name__,
data=data,
data_type=type(data).__name__,
yaml_str=yaml_str,
)

if inputs is not None:
# If the input is already a string, use it directly

connection.execute(
text("UPDATE action SET inputs = :yaml WHERE id = :id"),
{"yaml": yaml_str, "id": action_id},
)


def downgrade() -> None:
# First get all existing action inputs
connection = op.get_bind()
actions = connection.execute(text("SELECT id, inputs FROM action")).fetchall()

# Convert strings back to JSON
for action_id, inputs in actions:
if inputs is not None:
# Nonetypes are stored as empty strings
try:
data = yaml.safe_load(inputs)
except yaml.YAMLError:
data = inputs
data = data or {}
json_data = json.dumps(data)

logger.info(
"Downgrading action with inputs:",
inputs=inputs,
type=type(inputs).__name__,
data=data,
data_type=type(data).__name__,
json_data=json_data,
json_data_type=type(json_data),
)

connection.execute(
text("UPDATE action SET inputs = :json WHERE id = :id"),
{"json": json_data, "id": action_id},
)

# Then alter the column type back to JSONB with explicit USING clause
op.execute("ALTER TABLE action ALTER COLUMN inputs TYPE JSONB USING inputs::jsonb")
4 changes: 3 additions & 1 deletion deployments/aws/ecs/iam.tf
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ resource "aws_iam_policy" "secrets_access" {
}

resource "aws_iam_policy" "task_secrets_access" {
count = var.disable_temporal_autosetup ? 0 : 1
# Enable this policy if temporal autosetup is disabled
count = var.disable_temporal_autosetup ? 1 : 0
name = "TracecatTaskSecretsAccessPolicy"
description = "Policy for accessing Tracecat secrets at runtime"
policy = jsonencode({
Expand Down Expand Up @@ -159,6 +160,7 @@ resource "aws_iam_role_policy" "api_worker_task_db_access" {
})
}
resource "aws_iam_role_policy_attachment" "api_worker_task_secrets" {
# Enable this policy if temporal autosetup is disabled
count = var.disable_temporal_autosetup ? 1 : 0
policy_arn = aws_iam_policy.task_secrets_access[0].arn
role = aws_iam_role.api_worker_task.name
Expand Down
13 changes: 4 additions & 9 deletions frontend/src/client/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const $ActionRead = {
title: "Status",
},
inputs: {
type: "object",
type: "string",
title: "Inputs",
},
control_flow: {
Expand Down Expand Up @@ -339,15 +339,10 @@ export const $ActionUpdate = {
title: "Status",
},
inputs: {
anyOf: [
{
type: "object",
},
{
type: "null",
},
],
type: "string",
maxLength: 10000,
title: "Inputs",
default: "",
},
control_flow: {
anyOf: [
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ export type ActionRead = {
title: string
description: string
status: string
inputs: {
[key: string]: unknown
}
inputs: string
control_flow?: ActionControlFlow
}

Expand Down Expand Up @@ -111,9 +109,7 @@ export type ActionUpdate = {
title?: string | null
description?: string | null
status?: string | null
inputs?: {
[key: string]: unknown
} | null
inputs?: string
control_flow?: ActionControlFlow | null
}

Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/workbench/canvas/action-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Trash2Icon,
} from "lucide-react"
import { Node, NodeProps, useEdges } from "reactflow"
import YAML from "yaml"

import { useAction, useWorkflowManager } from "@/lib/hooks"
import { cn, isEmptyObjectOrNullish, slugify } from "@/lib/utils"
Expand Down Expand Up @@ -91,11 +92,12 @@ export default React.memo(function ActionNode({
const edges = useEdges()
const incomingEdges = edges.filter((edge) => edge.target === id)
const isChildWorkflow = action?.type === CHILD_WORKFLOW_ACTION_TYPE
const childWorkflowId = action?.inputs?.workflow_id
? String(action?.inputs?.workflow_id)
const actionInputsObj = action?.inputs ? YAML.parse(action?.inputs) : {}
const childWorkflowId = actionInputsObj?.workflow_id
? String(actionInputsObj?.workflow_id)
: undefined
const childWorkflowAlias = action?.inputs?.workflow_alias
? String(action?.inputs?.workflow_alias)
const childWorkflowAlias = actionInputsObj?.workflow_alias
? String(actionInputsObj?.workflow_alias)
: undefined

// Create a skeleton loading state within the card frame
Expand Down
57 changes: 25 additions & 32 deletions frontend/src/components/workbench/canvas/selector-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,17 @@ export default React.memo(function SelectorNode({
ref={inputRef}
className="!py-0 text-xs"
placeholder="Start typing to search for an action..."
onValueChange={(value) => setInputValue(value)}
onValueChange={(value) => {
// First update the value
setInputValue(value)
// Then force scroll to top of the list
requestAnimationFrame(() => {
const commandList = document.querySelector('[cmdk-list]')
if (commandList) {
commandList.scrollTop = 0
}
})
}}
autoFocus
/>
<CommandList className="border-b">
Expand All @@ -129,7 +139,7 @@ export default React.memo(function SelectorNode({
<Handle
type="target"
position={targetPosition ?? Position.Top}
isConnectable={false} // Prevent initiating a connection from the selector node
isConnectable={false}
className={cn(
"left-1/2 !size-8 !-translate-x-1/2 !border-none !bg-transparent"
)}
Expand All @@ -147,21 +157,12 @@ function ActionCommandSelector({
}) {
const { registryActions, registryActionsIsLoading, registryActionsError } =
useWorkbenchRegistryActions()
const scrollAreaRef = useRef<HTMLDivElement>(null)

// Add effect to reset scroll position when search results change
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0
}
}, [inputValue])

if (!registryActions || registryActionsIsLoading) {
return (
<ScrollArea className="h-full" ref={scrollAreaRef}>
<CommandGroup heading="Loading Actions..." className="text-xs">
{/* Render 5 skeleton items */}
{Array.from({ length: 5 }).map((_, index) => (
<ScrollArea className="h-full">
<CommandGroup heading="Loading actions..." className="text-xs">
{Array.from({ length: 3 }).map((_, index) => (
<CommandItem key={index} className="text-xs">
<div className="w-full flex-col">
<div className="flex items-center justify-start">
Expand All @@ -186,13 +187,15 @@ function ActionCommandSelector({
}

return (
<ScrollArea className="h-full" ref={scrollAreaRef}>
<ActionCommandGroup
group="Suggestions"
nodeId={nodeId}
registryActions={registryActions}
inputValue={inputValue}
/>
<ScrollArea className="h-full overflow-y-auto">
{filterActions(registryActions, inputValue).length > 0 && (
<ActionCommandGroup
group="Suggestions"
nodeId={nodeId}
registryActions={registryActions}
inputValue={inputValue}
/>
)}
</ScrollArea>
)
}
Expand Down Expand Up @@ -282,18 +285,8 @@ function ActionCommandGroup({
[getNode, nodeId, workflowId, workspaceId, setNodes, setEdges]
)

// Add ref for the command group
const commandGroupRef = useRef<HTMLDivElement>(null)

// Reset scroll position when filter results change
useEffect(() => {
if (commandGroupRef.current) {
commandGroupRef.current.scrollIntoView({ block: "start" })
}
}, [filterResults])

return (
<CommandGroup heading={group} className="text-xs" ref={commandGroupRef}>
<CommandGroup heading={group} className="text-xs">
{filterResults.map((result) => {
const action = result.obj

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/workbench/panel/action-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { z } from "zod"

import { RequestValidationError, TracecatApiError } from "@/lib/errors"
import { useAction, useWorkbenchRegistryActions } from "@/lib/hooks"
import { cn, itemOrEmptyString, slugify } from "@/lib/utils"
import { cn, slugify } from "@/lib/utils"
import {
Accordion,
AccordionContent,
Expand Down Expand Up @@ -101,7 +101,7 @@ const actionFormSchema = z.object({
inputs: z
.string()
.max(10000, "Inputs must be less than 10000 characters")
.optional(),
.default(""),
control_flow: z.object({
for_each: z
.string()
Expand Down Expand Up @@ -176,7 +176,7 @@ export function ActionPanel({
values: {
title: action?.title,
description: action?.description,
inputs: itemOrEmptyString(action?.inputs),
inputs: action?.inputs ?? "",
control_flow: {
for_each: stringifyYaml(for_each),
run_if: stringifyYaml(run_if),
Expand Down Expand Up @@ -212,7 +212,7 @@ export function ActionPanel({
const params: ActionUpdate = {
title: values.title,
description: values.description,
inputs: parseYaml(values.inputs) ?? {},
inputs: values.inputs,
control_flow: {
...parseYaml(values.control_flow.options),
for_each: parseYaml(values.control_flow.for_each),
Expand Down
105 changes: 57 additions & 48 deletions img/tracecat-template.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion tracecat/db/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,13 @@ class Action(Resource, table=True):
title: str
description: str
status: str = "offline" # "online" or "offline"
inputs: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSONB))
inputs: str = Field(
default="",
description=(
"YAML string containing input configuration. The default value is an empty "
"string, which is `null` in YAML flow style."
),
)
control_flow: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSONB))

workflow_id: uuid.UUID = Field(
Expand Down
3 changes: 2 additions & 1 deletion tracecat/dsl/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,12 @@ def build_action_statements(

action = id2action[node.id]
control_flow = ActionControlFlow.model_validate(action.control_flow)
args = yaml.safe_load(action.inputs) or {}
action_stmt = ActionStatement(
id=action.id,
ref=action.ref,
action=action.type,
args=action.inputs,
args=args,
depends_on=dependencies,
run_if=control_flow.run_if,
for_each=control_flow.for_each,
Expand Down
Loading

0 comments on commit da52f19

Please sign in to comment.