As a Detection Engineer, the first Detection-as-Code (DaC) project I had contact with was basically just a rule repository, nothing else. It had no way to help manage the ruleset or enforce standards. The result was all the friction of a Git workflow with only a fraction of the benefits. Some time later, I got the chance to revamp it and add the features I’d been reading about:
- Decoupled validation using external schemas
- Auxiliary scripts to export rule data for analysis
- A cleaner rule envelope containing only fields with a clear purpose
- A real multi-engine foundation
It was a huge leap for the team, though still short of what I had in mind before other priorities took over. A few months ago, I compiled those thoughts in Detection-as-Code, Then What?, focusing on the foundations a team needs in place to unlock real management capabilities later. Recently, I finally had the time to turn those ideas into working software — and with AI’s help, putting them into practice was much faster than I expected.
In this two-part series, I’ll walk through the result: Graft 🌿. Continuing my habit of biology-inspired names, the name comes from horticultural grafting: joining a shoot from one plant onto the rootstock of another so distinct varieties grow on a single trunk. This post covers the architectural decisions behind Graft, why they matter for a DaC platform, and how Google SecOps fit in as the first engine. The next post will cover the AI-assisted workflow I used to build it and walk through concrete use cases.
The Envelope and Dual-Track Rules
Detection-as-Code can’t be treated as a loose collection of scripts. Everything has to sit on top of a shared core for reuse and consistency. And before writing a single line of code, you have to define the rule envelope.
Detection logic alone is not a rule. A production detection is an operational software contract: it needs metadata, deployment controls, and a runbook for the tier-1 analyst on call — or an AI agent assisting with triage. Many DaC projects I’ve seen stuff too many fields into their YAML files. In my view, every field must earn its place by driving a concrete management or operational action. At the same time, the envelope has to accommodate multiple engines; a DaC system locked to a single platform defeats much of its own purpose.
When I moved from the theoretical 4-block proto-rule in Detection-as-Code, Then What? (metadata, logic, deployment, guide) to building Graft against live SIEM APIs, a few practical adjustments were necessary. The custom rule envelope settled into five blocks:
metadata: Rule identity, description, authors, references, and taxonomy.logic: The raw query string expected by the target engine.deployment: Engine-specific runtime parameters.runbook: Operational context, triage steps, and incident response actions — renamed fromguideto reflect how SOCs actually use it.tests: Synthetic events and expected match counts for validation.
In my earlier post, I avoided putting a name or id field inside metadata, arguing the filename was enough. Building the reconciliation engine changed my mind. If an engineer renames a YAML file and you don’t track an immutable metadata.id (UUID), the deployment pipeline sees a deleted rule and a brand-new one. In a SIEM, deleting and recreating a rule generates a new remote ID, wipes historical detection timelines, and breaks SOAR playbooks wired to the old identifier. Adding a UUID inside metadata solved that cleanly: you can rename files freely while Graft updates the existing rule in place.
For MITRE ATT&CK mappings (metadata.mitre), Graft pairs normalized tactic names with specific technique IDs (tactic: [technique_ids]). That structure is deliberate: it stops engineers from rubber-stamping a technique ID across the entire matrix regardless of which tactic actually applies to the rule’s context — something I wrote about in Mapping Detection Rules to MITRE ATT&CK. If a detection genuinely covers a technique across multiple tactics (like T1098.001 under both persistence and privilege-escalation below), the author has to declare each tactic explicitly. Finding the normalized tactic name is usually just lowercasing the tactic and replacing spaces with dashes (Privilege Escalation becomes privilege-escalation), with Graft’s bundled MITRE taxonomy file acting as the source of truth.
Using YAML here lets engineers write rules declaratively with clean multiline string support (|) for query logic and runbooks — an area where TOML gets awkward quickly. Here is what a custom rule looks like in Graft:
metadata:
id: "d5e2a481-7f32-4019-86c2-19e34b719002"
name: "gcp_iam_service_account_key_create"
description: "User-managed GCP service account key created."
authors:
- "Cloud Security Operations"
mitre:
persistence:
- "T1098.001"
privilege-escalation:
- "T1098.001"
tags:
- "gcp"
- "iam"
references:
- "https://cloud.google.com/iam/docs/creating-managing-service-account-keys"
logic: |
events:
$e.metadata.event_type = "USER_RESOURCE_CREATION"
$e.metadata.product_name = "GCP Cloud Audit"
$e.metadata.product_event_type = "google.iam.admin.v1.CreateServiceAccountKey"
$e.security_result.action = "ALLOW"
condition:
$e
deployment:
enabled: true
alerting: true
run_frequency: "live"
runbook:
context: "Service account keys are downloadable private key pairs providing persistent programmatic access to Google Cloud APIs."
triage: |
1. Identify the caller principal creating the key and originating IP address.
2. Review the target service account and its assigned IAM roles.
3. Check corresponding change management tickets for authorized key generation.
response: |
1. Invalidate and delete the created service account key immediately.
2. Rotate credentials for the calling identity if unauthorized activity is suspected.
tests:
- id: "match_service_account_key_creation"
description: "Fires an alert when a user-managed service account key is created"
expect: 1
events:
- timestamp: "2026-09-17T15:00:00Z"
payload:
metadata:
event_type: "USER_RESOURCE_CREATION"
product_name: "GCP Cloud Audit"
product_event_type: "google.iam.admin.v1.CreateServiceAccountKey"
security_result:
action: "ALLOW"If you know YARA-L, you’ll notice the logic block above omits the outer rule <name> { ... } wrapper and meta: section. That’s deliberate to keep the file DRY: YARA-L’s meta: fields overlap with the envelope’s metadata block, and maintaining them in two places inside the same file is an invitation for drift. Instead, the SecOps adapter (more on this later) reconstructs the full YARA-L rule on the fly, wrapping logic with the rule name and a minimal meta: block (id and description) for remote tracking. Future adapters can use the same pattern whenever a query language embeds metadata, while engines with simpler query structures will export logic as is — either way, the adapter abstracts it away.
Custom rules are only half the picture, though. Every detection engineering team also relies on out-of-the-box vendor rules, like Google SecOps’ Curated Detections. Most DaC repositories ignore managed rules entirely, leaving engineers to toggle rule sets and click through exclusions in the web UI. Graft handles both tracks under the same Git workflow: custom rules live in rulesets/<engine>/custom/, while vendor-managed rules live in a single consolidated rulesets/<engine>/managed.yaml manifest that tracks deployment states and tuning exclusions:
categories:
- name: "Linux Threats"
id: "a5366ed8-3746-2423-a972-98535279f96a"
rulesets:
- id: "1c4ab1f6-d801-d6a9-1177-3ec3dd5bcbe9"
name: "Malware Signals - Suspicious Execution"
deployments:
- type: PRECISE
enabled: true
alerting: true
- type: BROAD
enabled: true
alerting: false
exclusions:
- id: "exclude-backup-automation"
description: "Exclude overnight backup runner from Suspicious Execution"
ruleset_id: "1c4ab1f6-d801-d6a9-1177-3ec3dd5bcbe9"
expression: 'principal.hostname = "backup-server.corp.internal"'Architecture and Quality Gates
In a multi-engine envelope, blocks like metadata and runbook stay consistent everywhere, while logic and deployment vary by platform. To normalize what should be shared while giving each engine room for its own idiosyncrasies, I built Graft’s architecture around a Hexagonal Architecture (Ports and Adapters).
The core defines domain models and abstract port interfaces (RuleCompilerPort, RuleDeployerPort, ManagedEnginePort, ReplayHarnessPort) without importing a single SIEM SDK, cloud library, or HTTP client. Each engine lives in an isolated package (src/graft/engines/<engine>/) along with its own JSON schemas, and carries the full burden of authenticating, translating REST calls, compiling queries, and running tests. Core never adapts to an engine; engines adapt to Core. Bootstrapping a new target (graft new engine <name>) scaffolds the directory tree, schemas, and test stubs in seconds.
I also enforced a strict stdlib-first rule. Instead of pulling in third-party packages that expand the supply-chain attack surface and break across updates, Graft uses Python’s standard library — urllib.request for HTTP, argparse for the CLI, dataclasses for models, subprocess for Git. Runtime dependencies are capped at two stable libraries: pyyaml and jsonschema.
On top of this foundation, Graft enforces three quality gates before a rule reaches production:
- Offline schema and taxonomy checks (
graft lint): Validates YAML envelopes against JSON Schema Draft 2020-12 and checks every MITRE ATT&CK tag against a bundled Enterprise STIX matrix so typos and deprecated techniques fail locally in milliseconds, with no network access required. - Pre-merge compiler dry runs (
graft <engine> verify): Sends the rule logic to the remote engine’s syntax validator without saving or enabling the rule, catching query syntax errors during pull request checks without generating phantom alerts. - [EXPERIMENTAL] Quarantined synthetic replay (
graft <engine> test): Takes the synthetic events and expected match counts from thetestsblock, injects them into an isolated staging environment, runs the rule, and asserts that the detection count matchesexpectbefore cleaning up inside afinallyblock.
To support those gates across different team budgets, Graft works in both dual-tenant (staging + production) and single-tenant (production-only) modes. In a dual-tenant setup, pull request checks (verify and test) hit an isolated staging instance while merges to main deploy to production. Not every team has a second SIEM tenant, though. If staging coordinates aren’t configured, Graft automatically falls back to the production instance for safe, read-only or dry-run checks like verify, while a hard guard in code refuses to run synthetic event replay (test) against production.
Day-to-Day Operations and Visibility
Once a DaC system is live, Git becomes the single source of truth. If someone edits a rule or toggles a curated ruleset directly in the SIEM console, Graft treats it as drift: the Git baseline prevails and heals the engine back to the committed state.
That behavior needs two safeguards in practice. First, during a pull request, you only want to evaluate the files touched in your branch, so graft <engine> diff and apply run in scoped mode by default, while --all scans the full tenant catalog to catch and heal out-of-band console edits. Second, on Day 0 of adopting DaC on an existing SIEM, pushing an empty repository would be a disaster. Graft includes a pull command (graft <engine> pull) that temporarily treats the engine as the source of truth, fetching live custom rules and managed settings from the tenant and creating the YAML files locally. It’s a huge time saver during adoption, turning brownfield onboarding from days of manual copy-pasting into a five-minute command.
$ graft secops diff --env production
=== Custom Rules Diff ===
[+] Custom rule to create: gcp_storage_iam_public_access_granted
[~] Custom rule to update: workspace_nrd_possible_phishing (ID: ru_12345678-abcd-ef01-2345-6789abcdef01)
[?] Untracked custom rule on tenant: legacy_unmanaged_alert (ID: ru_98765432-feee-dcba-0000-111122223333)
=== Google SecOps Managed Content Diff ===
[~] Deployment: ur_cloud_threats (PRECISE) | enabled: False -> True, alerting: False -> True
[+] Exclusion to create: excl_cloud_functions_pipeline_saWhen a rule needs to be retired, deleting the file from Git makes its context harder to find later. In Graft, retiring a rule is just moving it into rulesets/<engine>/_archived/. Any underscore-prefixed directory is automatically excluded from active sync, linting, and coverage exports, while keeping the runbook and test vectors intact for audits.
Another piece that gets neglected in almost every DaC project I’ve worked with is operational logging. In a GitOps pipeline, a failed deployment is an operational incident. When a batch of rules or exclusions fails halfway through apply and the output is either silent or dumps a bare error string, detection engineers can’t troubleshoot it on their own — they have to pull in the developers who built the pipeline just to find out what broke, which wastes time and frustrates everyone. Even though a DaC tool is limited by the error payload the target engine returns, a failed run should still answer four questions immediately from the log:
- when it happened (in UTC, to correlate with CI runners and SIEM audit trails),
- what detection artifact was being mutated,
- where and why the vendor API rejected it (HTTP method, endpoint, vendor status code, and whether a multi-step call left the resource partially updated), and
- batch progress (what already changed on the tenant versus what was never reached).
Instead of sprinkling logger.info calls everywhere, Graft splits logging responsibilities along its hexagonal boundaries. The CLI layer handles formatting, ISO-8601 UTC timestamps, verbosity (-v, -q, --json), and exit codes. The Core reconciler logs lifecycle transitions, attributes failures to the exact rule or exclusion, and tracks batch progress (applied, failed, pending). The Engine adapters handle the transport and compiler details that Core doesn’t know about: subtracting the synthesized 4-line YARA-L header offset during verify so compiler line numbers match your YAML file, emitting [WARNING] lines during HTTP 429/503 backoff retries or when a non-atomic two-step write (POST rules followed by PATCH rules/{id}/deployment) succeeds on step 1 but fails on step 2, and attaching the HTTP verb, endpoint, and vendor status (INVALID_ARGUMENT, PERMISSION_DENIED) to exceptions:
$ graft secops apply --env production
2026-09-29T10:15:30Z [INFO] Updating custom rule 'gcp_iam_service_account_key_create' (ru_11111111-1111-1111-1111-111111111111)
2026-09-29T10:15:31Z [INFO] Updating custom rule 'workspace_nrd_possible_phishing' (ru_22222222-2222-2222-2222-222222222222)
2026-09-29T10:15:31Z [ERROR] Failed updating custom rule 'workspace_nrd_possible_phishing' (ru_22222222-2222-2222-2222-222222222222): SecOps API Error 400 (INVALID_ARGUMENT) on PATCH rules/ru_22222222-2222-2222-2222-222222222222: parsing: error with token: "="
2026-09-29T10:15:31Z [ERROR] Custom rules reconciliation aborted: 1/3 applied ['gcp_iam_service_account_key_create'], 1 failed ['workspace_nrd_possible_phishing'], 1 pending ['gcp_storage_iam_public_access_granted']Because reconciliation in Graft is idempotent and driven by live tenant state, recovering from a partial batch failure is straightforward: the analyst fixes the broken rule and re-runs apply. The rule that already succeeded is skipped as a no-op, and the remaining rules converge cleanly.
That brings us to the management side: the “then what?” from my previous post. You won’t find a maturity score or a static status field inside Graft’s rule envelope. In my experience, manual maturity labels rot the week after they’re written, and arbitrary 0–100 scores are mostly security theater. Instead, graft export catalog pulls factual lifecycle indicators straight from Git history (created_at, last_modified_at, commit review_count, and unique contributor_count) and combines them with envelope data (has_runbook, mitre_attack, and deployment state):
$ graft export catalog
Rule Name Engine Status MITRE ATT&CK Reviews Runbook Updated
------------------------------------- ------ ------- ---------------------------------- ------- ------- --------------------
gcp_iam_service_account_key_create secops enabled TA0003:T1098.001, TA0004:T1098.001 1 yes 2026-09-22T14:01:52Z
gcp_storage_iam_public_access_granted secops enabled TA0001:T1078.004, TA0112:T1685 2 yes 2026-09-22T15:17:52Z
workspace_nrd_possible_phishing secops enabled TA0001:T1566.002 1 yes 2026-09-22T14:01:52ZExporting this catalog to CSV (graft export catalog --format csv) is built for day-to-day management: a team lead can open it in a spreadsheet (or join it with SIEM alert volume in BigQuery) and immediately spot which rules haven’t been reviewed in six months, which ones lack a runbook, or where MITRE mappings are thin.
For threat coverage, graft export matrix --format navigator generates MITRE ATT&CK v19.2 Navigator layer files per engine (--engine secops, --engine sentinel, and so on). Exporting each engine as its own layer lets you overlay them in the official ATT&CK Navigator to see where engines overlap and answer the classic leadership question (“how’s our coverage?”) with real deployment state behind it.
Google SecOps as the First Engine
Google SecOps is the first engine adapter in Graft. Picking it first was a practical choice: I’ve worked with the platform for over three years (starting back when it was Chronicle), and now that I work at Google, I have a running lab environment to test against.
Graft is a personal research project, not an official Google product, and the opinions here are my own.
Few people talk about the Google SecOps REST API (https://<region>-chronicle.googleapis.com) from a Detection-as-Code perspective, and working with it at a low level was a pleasant surprise. The endpoints are fast and map cleanly to almost every feature I wanted in Graft:
- Non-destructive syntax validation (
instances:verifyRuleText): This endpoint impressed me the most. YouPOSTa raw YARA-L 2.0 string to:verifyRuleText, and the backend compiler validates it and returns line-and-column diagnostics immediately without creating a rule or touching live state. It made pre-merge CI dry runs trivial to wire up. - Custom rule CRUD and 2-call catalog diffs (
rules): CallingGET rules?view=FULLalongside the wildcard deployment endpointGET rules/-/deploymentsfetches every custom rule and its deployment state across the entire tenant in just two HTTP requests. Creating a rule is aPOST rulesfollowed byPATCH rules/{id}/deployment. When an existing rule changes,PATCH rules/{id}?update_mask=textcompiles a new revision in place under the existingru_<uuid>identifier, andPATCH rules/{id}/deployment?update_mask=enabled,alertingupdates its runtime flags separately (withDELETE rules/{id}available for teardown). If nothing changed in Git, Graft sends zero writes and creates zero duplicate revisions. - Managed content and exclusions via API (
curatedRuleSetsandfindingsRefinements): Using the-wildcard again, Graft bulk-fetches all vendor rule sets and their deployment states viaGET curatedRuleSetCategories/-/curatedRuleSetsandGET curatedRuleSetCategories/-/curatedRuleSets/-/curatedRuleSetDeployments, and togglesPRECISEorBROADtiers viaPATCH .../curatedRuleSetDeployments/{precise|broad}?update_mask=enabled,alerting. Detection exclusions (findingsRefinements) follow a similar two-step model:POSTorPATCH findingsRefinementsfor the UDM filter query, andPATCH findingsRefinements/{id}/deploymentto bind it to target rulesets (or retire it by settingenabled: false, archived: true, since exclusions are archived rather than hard-deleted).
One quirk worth knowing if you build against this API today: endpoints are currently split between v1 and v1alpha. Google has been migrating Chronicle’s legacy APIs into the unified chronicle.googleapis.com surface; custom rules and :verifyRuleText are already on GA v1, while Curated Rule Sets and findingsRefinements still live under v1alpha as that consolidation continues. In Graft, the HTTP client defaults to v1 and lets individual adapter methods pass api_version="v1alpha", so when those remaining endpoints graduate to v1, switching over is a one-line change.
Although the DaC revamp I mentioned earlier also had Google SecOps as its primary detection engine, my role there was defining goals, features, and technical direction as a Detection Engineer. We had dedicated developers on the team who wrote the integration code, so I had little hands-on contact with the API at the time — a gap I finally closed while building Graft.
The only piece from my blueprint that the public SecOps API doesn’t support yet is synchronous event injection and ad-hoc rule evaluation for the tests block. Because ingestion and retrohunts run asynchronously, you can’t yet pass a small array of mock UDM events to an endpoint and get an immediate pass/fail count back during a quick CI job. That’s why I still mark tests as experimental — honestly, I don’t know of another SIEM engine that exposes synchronous inline replay either. Even when engines start supporting it, a feature like that only makes sense against a staging tenant: injecting synthetic attack events into a production SIEM risks triggering real response workflows and creates compliance headaches, since production security logs are closely watched by auditors.
What Comes Next
A Detection-as-Code platform has to earn the friction it introduces. If it’s just a folder of queries in Git, engineers will resent the extra steps. Every design choice we covered in this post maps directly to a problem I’ve hit in production:
- Bloated rule files and broken renames: A lean 5-block YAML envelope (
metadata,logic,deployment,runbook,tests) tracked by an immutable UUID so files can be renamed freely, with DRY query synthesis and tactic-scoped MITRE mappings. - Unmanaged vendor detections: A dual-track workflow that governs vendor rule sets and tuning exclusions (
managed.yaml) right alongside custom rules. - SIEM lock-in and dependency bloat: A stdlib-first Hexagonal Architecture where Core stays engine-agnostic, isolated adapters absorb vendor API quirks, and
graft newscaffolds new engines or rules in seconds. - Broken queries reaching production: Three quality gates (offline schema and MITRE linting, remote compiler dry runs, and quarantined replay — respectively,
lint,verify,test) that work across both dual-tenant (staging+production) and single-tenant (production-only) budgets. - Brownfield onboarding and console drift: CLI workflows for Day-0 tenant imports (
pull), scoped PR checks and full-tenant drift healing (diffandapply), and audit-safe rule retirement (_archived/). - Opaque pipeline failures: Operational logging split across architectural layers, giving analysts the exact UTC timestamp, failed artifact, HTTP endpoint, vendor error, and batch progress needed to troubleshoot on their own.
- Rotting maturity labels and manual coverage tracking: Automated CLI exports that pull factual lifecycle metrics straight from Git history (
export catalog) and generate per-engine ATT&CK Navigator layers (export matrix).
Graft today is a working ~10,500-line Python codebase with strict typing (mypy --strict), zero cloud SDK dependencies, and over 200 unit tests that run in a few seconds. In the next post, I’ll go behind the scenes of how I built it: the AI-assisted engineering workflow I used with Gemini and Google’s internal agentic coding harness, the mistakes I made along the way, and concrete use cases showing Graft in action.
Reuse
Citation
@online{lopes2026,
author = {Lopes, Joe},
title = {Graft 1: {Detection-as-Code,} {Implemented}},
date = {2026-09-29},
url = {https://lopes.id/log/graft-1-dac-implemented/},
langid = {en}
}