SecOps with Kestra
Operationalize SecOps benchmarks with Kestra.
Overview
This how-to shows how to operationalize SecOps benchmarks with Kestra. You will download a CIS benchmark, store control recommendations as settings, and orchestrate compliance scans and automated remediation across multiple controls and teams.
Prerequisites
- Access to the CIS benchmark for your target operating system (Ubuntu 24.04 LTS in this example)
- A Kestra namespace strategy for SecOps (for example
company.security.cis.linux.ubuntu.22-04-lts.devops) - SSH access (public key) to the target VMs you plan to scan/remediate
- Appropriate secrets configured in Kestra for usernames, private keys, and webhook triggers
Step 1: Download the Benchmark
- Go to https://downloads.cisecurity.org/#/ and download the CIS_Ubuntu_Linux_24.04_LTS_Benchmark_v1.0.0 (or the benchmark that matches your OS).
- Review the controls you plan to enforce and note the recommended settings.

Step 2: Define the Namespace and Settings Structure
- Decide how to segment namespaces per team or environment. Examples:
company.security.cis.linux.ubuntu.22-04-lts.devopscompany.security.cis.linux.ubuntu.22-04-lts.dataeng
- Create settings (KV pairs) for every control you want to validate. For instance, controls under section 1.6:

And they can be stored by following this hierarchy:
1
├── 1.1
│ ├── 1.1.1
│ │ ├── 1.1.1.1
│ │ └── 1.1.1.2
└── 1.6
└── 1.6.4
- Use consistent KV naming so any flow can dynamically fetch a control setting. Example naming convention:
control-1-1_6-1_6_4for control 1.6.4. - Store the recommended permission string or configuration snippet for each control. Control 1.6.4, for example, ensures
/etc/motdpermissions follow security guidance.

Repeat this process for every control you intend to enforce. The walkthrough below focuses on 1.6.4, 1.6.5, and 1.6.6.
Step 3: Store Secrets for VM Access
- Add secrets for the SSH username (
vmUser) and private key (vmKey) used to connect to the VM. - Store any additional secrets (for example, webhook secrets) you will reference in flows and triggers.


Step 4: Model the Parent Flow
Design a flow that evaluates each control, remediates if required, and proceeds to the next control. At a high level the logic looks like this:
Start → Execute Control 1.6.4 → Assess Compliance
→ If compliant → Move to next control
→ If not compliant → Remediate → Re-assess → Next control

Step 5: Create Reusable Control Subflows
Create a subflow per control so you can reuse the same logic across namespaces. The example below implements control 1.6.5. Note how periods in the control number are converted to underscores for IDs (for example, 1_6_5).
id: control-1-1_6-1_6_5
namespace: company.security.cis.linux.ubuntu.22-04-lts.devops
inputs:
- id: remediateControls
description: Toggle ON to auto-remediate non-compliant controls.
displayName: Auto Remediate
type: BOOL
defaults: true
- id: ipAddress
type: STRING
defaults: localhost
variables:
COMPLIANT: Compliant
NOT_COMPLIANT: Not Compliant
tasks:
# Retrieve the recommended configuration from the KV store
- id: getConfiguration
type: io.kestra.plugin.core.kv.Get
key: "{{ render(flow.id) }}"
# Assess the current VM state
- id: assess-1_6_5
type: io.kestra.plugin.fs.ssh.Command
host: "{{ inputs.ipAddress }}"
authMethod: PUBLIC_KEY
username: "{{ secret('vmUser') }}"
privateKey: "{{ secret('vmKey') }}"
commands:
- 'echo $(stat -Lc "Access: (%#a/%A) Uid: ( %u/ %U) Gid: { %g/ %G)" /etc/issue) > output.log'
- echo '::{"outputs":{"result":"'$(cat output.log)'"}}::'
- id: status-1_6_5
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs['assess-1_6_5']['vars']['result'] == outputs.getConfiguration.value }}"
then:
- id: compliant-1_6_5
type: io.kestra.plugin.core.debug.Return
format: "{{ vars.COMPLIANT }}"
else:
- id: doRemediate
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.remediateControls == true }}"
then:
- id: remediate-1_6_5
type: io.kestra.plugin.fs.ssh.Command
host: "{{ inputs.ipAddress }}"
username: "{{ secret('vmUser') }}"
privateKey: "{{ secret('vmKey') }}"
authMethod: PUBLIC_KEY
commands:
- sudo chown root:root $(readlink -e /etc/issue)
- sudo chmod u-x,go-wx $(readlink -e /etc/issue)
- id: remediateResult-1_6_5
type: io.kestra.plugin.core.debug.Return
format: "{{ vars.COMPLIANT }}"
else:
- id: not-compliant-1_6_5
type: io.kestra.plugin.core.debug.Return
format: "{{ vars.NOT_COMPLIANT }}"
# Return output for the parent flow
outputs:
- id: complianceStatus-1_6_5
type: STRING
value: "{{ outputs['compliant-1_6_5']['value'] ?? outputs['remediateResult-1_6_5']['value'] ?? outputs['not-compliant-1_6_5']['value'] ?? 'Error' }}"
Repeat the same pattern for controls 1.6.4 and 1.6.6.
Step 6: Assemble the Parent Flow
Use the subflows inside a parent orchestration that evaluates each control sequentially within a Parallel task (with concurrency set to 1). This lets you retrigger individual control branches without re-running the entire benchmark.
id: csrRevamped
namespace: company.security.cis.linux.ubuntu.22-04-lts.devops
inputs:
- id: remediateControls
description: Toggle ON to auto-remediate non-compliant controls.
displayName: Auto Remediate
type: BOOL
defaults: true
- id: ipAddress
displayName: IP Address
description: Host on which the scan must run.
type: STRING
defaults: localhost
tasks:
- id: section-1-1_6
type: io.kestra.plugin.core.flow.Parallel
# Tasks run in parallel but concurrency is limited to 1 so each control
# can be retriggered independently without re-running downstream steps.
concurrent: 1
tasks:
- id: trigger-1-1_6-1_6_4
type: io.kestra.plugin.core.flow.Sequential
tasks:
- id: control-1-1_6-1_6_4
type: io.kestra.plugin.core.flow.Subflow
namespace: "{{ flow.namespace }}"
flowId: control-1-1_6-1_6_4
inputs:
ipAddress: "{{ inputs.ipAddress }}"
remediateControls: "{{ inputs.remediateControls }}"
wait: true
transmitFailed: true
- id: logStatus-1-1_6-1_6_4
type: io.kestra.plugin.core.log.Log
message: "{{ outputs['control-1-1_6-1_6_4'].outputs['complianceStatus-1_6_4'] }}"
- id: trigger-1-1_6-1_6_5
type: io.kestra.plugin.core.flow.Sequential
tasks:
- id: control-1-1_6-1_6_5
type: io.kestra.plugin.core.flow.Subflow
namespace: "{{ flow.namespace }}"
flowId: control-1-1_6-1_6_5
inputs:
ipAddress: "{{ inputs.ipAddress }}"
remediateControls: "{{ inputs.remediateControls }}"
wait: true
transmitFailed: true
- id: logStatus-1-1_6-1_6_5
type: io.kestra.plugin.core.log.Log
message: "{{ outputs['control-1-1_6-1_6_5'].outputs['complianceStatus-1_6_5'] }}"
# These triggers will be demonstrated in the VM creation and ServiceNow tutorial
triggers:
- id: vmCreateFromServiceNow
type: io.kestra.plugin.core.trigger.Webhook
key: "{{ secret('webHookTriggerSecret') }}"
- id: postVMCreation
type: io.kestra.plugin.core.trigger.Flow
inputs:
ipAddress: "{{ trigger.outputs.externalIPAddress }}"
preconditions:
id: vmCreationSuccess
flows:
- namespace: company.ops.it
flowId: createVMRevamped
states: [ SUCCESS, WARNING ]
Step 7: Review the Topology
Each control runs in parallel but only one at a time because concurrent: 1. This makes it easy to rerun non-compliant controls individually without re-running the entire benchmark.

Demo
- Execute the flow. Observe the initial compliance check.

- Check the results. Review the compliance summary.

- Inspect the subflow. Confirm whether the VM was already compliant.

- Force a drift. Change the VM setting for control
1_6_5(for example, from644to664).
- Retrigger only control
1_6_5.
- Review the logs. Verify that remediation executed for
1_6_5.
- Validate the VM permissions. Confirm they returned to
644.
Result
You have enforced CIS benchmark controls through Kestra, combined compliance assessment with optional remediation, and validated that individual controls can be retriggered independently. Replace the placeholder images with real screenshots from your environment to complete the documentation.
Was this page helpful?