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
292 changes: 292 additions & 0 deletions flows/aikido-compliance-drift-evidence-pack.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
id: aikido-compliance-drift-evidence-pack
namespace: company.security

inputs:
- id: reporting_period
type: STRING
defaults: 2026-Q3
description: Label stored with the evidence pack so an auditor can line packs up with a control period.

- id: drift_alert_threshold
type: INT
defaults: 1
description: How many controls may regress before the flow raises an alert rather than filing quietly.

tasks:
- id: collect_frameworks
type: io.kestra.plugin.core.flow.Parallel
description: Pull all three framework overviews at once. Each is a separate Aikido endpoint and they do not depend on each other.
tasks:
- id: soc2
type: io.kestra.plugin.aikido.compliance.GetReport
clientId: "{{ secret('AIKIDO_CLIENT_ID') }}"
clientSecret: "{{ secret('AIKIDO_CLIENT_SECRET') }}"
framework: SOC2

- id: iso27001
type: io.kestra.plugin.aikido.compliance.GetReport
clientId: "{{ secret('AIKIDO_CLIENT_ID') }}"
clientSecret: "{{ secret('AIKIDO_CLIENT_SECRET') }}"
framework: ISO27001

- id: nis2
type: io.kestra.plugin.aikido.compliance.GetReport
clientId: "{{ secret('AIKIDO_CLIENT_ID') }}"
clientSecret: "{{ secret('AIKIDO_CLIENT_SECRET') }}"
framework: NIS2

- id: open_findings
type: io.kestra.plugin.aikido.issues.ListOpen
description: The open critical and high findings are the evidence behind a failing vulnerability management control.
clientId: "{{ secret('AIKIDO_CLIENT_ID') }}"
clientSecret: "{{ secret('AIKIDO_CLIENT_SECRET') }}"
severities:
- CRITICAL
- HIGH
fetchType: FETCH

- id: read_previous
type: io.kestra.plugin.core.kv.Get
description: Load the previous posture snapshot so the flow reports movement rather than a static score.
key: compliance_posture
errorOnMissing: false

- id: detect_drift
type: io.kestra.plugin.scripts.python.Script
description: Compare each control against the previous snapshot and separate regressions from improvements.
containerImage: python:3.12-slim
inputFiles:
current.json: |
{
"soc2": {"overview": {{ outputs.soc2.overview | toJson }}, "complying": {{ outputs.soc2.totalComplyingRuleCount }}, "total": {{ outputs.soc2.totalRuleCount }}},
"iso27001": {"overview": {{ outputs.iso27001.overview | toJson }}, "complying": {{ outputs.iso27001.totalComplyingRuleCount }}, "total": {{ outputs.iso27001.totalRuleCount }}},
"nis2": {"overview": {{ outputs.nis2.overview | toJson }}, "complying": {{ outputs.nis2.totalComplyingRuleCount }}, "total": {{ outputs.nis2.totalRuleCount }}}
}
previous.json: "{{ outputs.read_previous.value ?? '{}' }}"
script: |
import json

with open("current.json") as fh:
current = json.load(fh)
with open("previous.json") as fh:
raw = fh.read().strip()
previous = json.loads(raw) if raw else {}

def controls(snapshot, framework):
data = (snapshot.get(framework) or {}).get("overview") or {}
flat = {}
for key, value in data.items():
if isinstance(value, dict):
flat[key] = str(value.get("status", "unknown"))
else:
flat[key] = str(value)
return flat

regressions = []
improvements = []
scores = {}

for framework in ("soc2", "iso27001", "nis2"):
now_controls = controls(current, framework)
was_controls = controls(previous, framework)
for name, status in now_controls.items():
before = was_controls.get(name)
if before is None:
continue
if before != status:
entry = {"framework": framework, "control": name, "from": before, "to": status}
if status.lower() in ("failing", "false", "non_complying"):
regressions.append(entry)
else:
improvements.append(entry)
complying = current[framework]["complying"]
total = current[framework]["total"]
was_complying = (previous.get(framework) or {}).get("complying")
scores[framework] = {
"complying": complying,
"total": total,
"pct": round(100.0 * complying / total, 1) if total else 0.0,
"delta": (complying - was_complying) if isinstance(was_complying, int) else None,
}

def describe(items):
if not items:
return "none"
return "; ".join(f"{i['framework']} {i['control']} ({i['from']} to {i['to']})" for i in items).replace('"', "'")

summary = {
"first_run": not previous,
"regressions": regressions,
"improvements": improvements,
"regression_count": len(regressions),
"improvement_count": len(improvements),
"regressions_text": describe(regressions),
"improvements_text": describe(improvements),
"scores_text": "; ".join(
f"{k.upper()} {v['complying']}/{v['total']} ({v['pct']}%)" + (f" delta {v['delta']:+d}" if v["delta"] is not None else "")
for k, v in scores.items()
),
"snapshot": current,
}

print("::" + json.dumps({"outputs": summary}) + "::")
print(f"regressions={len(regressions)} improvements={len(improvements)}")

- id: write_evidence
type: io.kestra.plugin.ai.agent.AIAgent
description: Turn the posture delta into the narrative an auditor actually asks for, grounded only in the numbers above.
provider:
type: io.kestra.plugin.ai.provider.OpenAI
modelName: gpt-4o-mini
apiKey: "{{ secret('OPENAI_API_KEY') }}"
configuration:
temperature: 0.2
systemMessage: |
You write control evidence narratives for a security compliance auditor.
Use only the figures supplied. Never invent a control, a date, or a remediation that was not given to you.
Write three short paragraphs of plain prose: current posture, what changed since the last period and why, and what compensating controls or remediation work is in flight.
Do not use bullet points and do not use headings.
prompt: |
Reporting period: {{ inputs.reporting_period }}
Framework scores: {{ outputs.detect_drift.vars.scores_text }}
Controls that regressed: {{ outputs.detect_drift.vars.regressions_text }}
Controls that improved: {{ outputs.detect_drift.vars.improvements_text }}
Open critical and high findings currently tracked in Aikido: {{ outputs.open_findings.size }}
First run with no prior snapshot: {{ outputs.detect_drift.vars.first_run }}

- id: archive_pack
type: io.kestra.plugin.core.storage.Write
description: Persist the evidence pack to internal storage so the URI can be attached to an audit request later.
extension: .json
content: |
{
"reporting_period": "{{ inputs.reporting_period }}",
"generated_at": "{{ now() }}",
"execution_id": "{{ execution.id }}",
"scores": "{{ outputs.detect_drift.vars.scores_text }}",
"regressions": {{ outputs.detect_drift.vars.regressions | toJson }},
"improvements": {{ outputs.detect_drift.vars.improvements | toJson }},
"open_critical_high": {{ outputs.open_findings.size }},
"narrative": {{ outputs.write_evidence.textOutput | toJson }}
}

- id: record_snapshot
type: io.kestra.plugin.core.kv.Set
description: Store this period's posture as the comparison point for the next run.
key: compliance_posture
kvType: JSON
overwrite: true
value: "{{ outputs.detect_drift.vars.snapshot | toJson }}"

- id: drift_alert
type: io.kestra.plugin.core.flow.If
description: A regression is a finding in its own right, so it is announced rather than left inside the archived pack.
condition: "{{ outputs.detect_drift.vars.regression_count >= inputs.drift_alert_threshold }}"
then:
- id: announce_drift
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Compliance drift detected for {{ inputs.reporting_period }}",
"blocks": [
{"type": "header", "text": {"type": "plain_text", "text": "Compliance posture regressed"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Period* {{ inputs.reporting_period }}\n*Scores* {{ outputs.detect_drift.vars.scores_text }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Regressed controls*\n{{ outputs.detect_drift.vars.regressions_text }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Improved controls*\n{{ outputs.detect_drift.vars.improvements_text }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "Evidence pack archived at `{{ outputs.archive_pack.uri }}` from execution {{ execution.id }}."}}
]
}
else:
- id: log_stable
type: io.kestra.plugin.core.log.Log
message: "EVIDENCE pack filed for {{ inputs.reporting_period }} with no regression. {{ outputs.detect_drift.vars.scores_text }}. Pack at {{ outputs.archive_pack.uri }}."

errors:
- id: evidence_failed
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": ":warning: Compliance evidence pack for {{ inputs.reporting_period }} failed to generate. No snapshot was recorded, so the next run still compares against the previous period. Execution {{ execution.id }}."
}

outputs:
- id: evidence_uri
type: STRING
value: "{{ outputs.archive_pack.uri }}"

- id: regression_count
type: INT
value: "{{ outputs.detect_drift.vars.regression_count }}"

extend:
shortDescription: Snapshot SOC2, ISO 27001 and NIS2 posture from Aikido, detect which controls regressed since last period, and file an AI-written evidence pack.
title: Turn Aikido Compliance Reports into a Dated Evidence Pack That Shows Control Drift, Not Just a Score
metaTitle: Compliance Drift Evidence Packs with Aikido and Kestra
metaDescription: Pull SOC2, ISO 27001 and NIS2 overviews from Aikido, diff controls against the last period, and archive an auditor-ready evidence narrative with Kestra.
description: |
A compliance dashboard tells you where you stand today. An auditor asks a harder question: what changed since last period, and what did you do about it. That answer is usually reconstructed by hand from screenshots and memory. This blueprint builds it automatically. It snapshots SOC2, ISO 27001 and NIS2 posture from Aikido, diffs every control against the previous snapshot, separates genuine regressions from improvements, and has an AI agent write the narrative grounded strictly in those figures. The result is archived as a dated evidence pack with a stable URI, and any regression is announced as a finding rather than buried in an attachment.

## How it works
1. The `collect_frameworks` task (`io.kestra.plugin.core.flow.Parallel`) fetches all three framework overviews concurrently through `io.kestra.plugin.aikido.compliance.GetReport`. They are independent endpoints, so there is no reason to fetch them in series.
2. The `open_findings` task (`io.kestra.plugin.aikido.issues.ListOpen`) pulls current critical and high findings, which are the actual evidence behind a failing vulnerability management control.
3. The `read_previous` task (`io.kestra.plugin.core.kv.Get`) loads last period's snapshot with `errorOnMissing: false`, so the first run establishes a baseline instead of failing.
4. The `detect_drift` task (`io.kestra.plugin.scripts.python.Script`) walks every control in every framework, compares it to its previous status, and classifies the change as a regression or an improvement. It also computes the complying-rule delta per framework, which is the number that actually moves in a board report.
5. The `write_evidence` task (`io.kestra.plugin.ai.agent.AIAgent`) writes three plain paragraphs from those figures. The system message forbids inventing controls, dates, or remediation, which is the difference between an evidence pack and a liability.
6. The `archive_pack` task (`io.kestra.plugin.core.storage.Write`) persists the structured pack plus the narrative to internal storage and returns a URI you can attach to an audit request.
7. The `record_snapshot` task stores the current posture as the comparison point for next period, and `drift_alert` announces regressions to Slack.

## What you get
- Control-level drift detection, not just a percentage that moved.
- A dated, archived evidence pack per reporting period with a stable storage URI.
- An AI narrative constrained to the supplied figures, so it reads well without inventing facts.
- Complying-rule deltas per framework, the figure leadership actually tracks.
- Regressions surfaced as alerts the moment they appear rather than at audit time.

## Who it's for
- Compliance and GRC teams preparing SOC2 or ISO 27001 evidence on a recurring cadence.
- Security leads who have to explain a posture change to an auditor months after it happened.
- Teams in scope for NIS2 who need demonstrable, dated control monitoring.

## Why orchestrate this with Kestra
Drift detection is inherently stateful: you cannot compute it without last period's numbers, and last period's numbers have to live somewhere trustworthy. Kestra gives you a durable KV snapshot, a parallel fetch across three endpoints, an archived artifact with a permanent URI tied to an execution id, and a full audit trail of when each pack was generated and from which figures. The alternative is a notebook someone runs by hand, whose output nobody can later prove corresponds to a particular day. The execution record is itself part of the evidence.

## Prerequisites
- An Aikido workspace with API credentials carrying the `reports:read` and `issues:read` scopes.
- Aikido compliance monitoring enabled for the frameworks you want to report on. Remove any framework task you do not subscribe to.
- An OpenAI API key for the narrative agent, or swap in another provider from the Kestra AI plugin.
- Docker available to the Kestra worker for the Python drift step.

### Secrets
- `AIKIDO_CLIENT_ID`, `AIKIDO_CLIENT_SECRET`: OAuth2 credentials for the Aikido public API.
- `OPENAI_API_KEY`: key used by the evidence narrative agent.
- `SLACK_WEBHOOK`: incoming webhook used for drift alerts and failures.

## Quick start
1. Add the secrets above to your Kestra namespace.
2. Remove any of the three framework tasks your workspace does not track. The Python step reads whichever remain.
3. Run the flow once to establish the baseline snapshot. The first run reports no drift by design.
4. Run it again at the end of your next control period with the matching `reporting_period` label.
5. Attach the `evidence_uri` output to your audit evidence request.

## How to extend
- Fetch Aikido's own PDF report from the `/report/export/pdf` endpoint with `io.kestra.plugin.core.http.Request` and archive it next to the narrative for a complete pack.
- Add GDPR, CIS, PCI or DORA once those overviews are exposed by the plugin, or reach them directly over HTTP in the meantime.
- Upload the pack to object storage with `io.kestra.plugin.aws.s3.Upload` so it lands in an immutable, versioned bucket.
- Add a `Pause` task so the compliance owner signs off on the narrative before it is archived as final.
- Post the quarter-over-quarter complying-rule delta to a dashboard with `io.kestra.plugin.core.http.Request` for a trend line across periods.
- Correlate each regressed control with the Aikido issues that caused it by filtering `issues.Export` on the relevant repository or team.

## Links
- Aikido plugin docs: https://kestra.io/plugins/plugin-aikido
- Kestra KV store: https://kestra.io/docs/concepts/kv-store
- Kestra AI agents: https://kestra.io/docs/ai-tools
tags:
- Business
- AI
subTags:
- Policy & Compliance
- Reporting & Digests
ee: false
demo: false
Loading
Loading