<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Alex Ott on Medium]]></title>
        <description><![CDATA[Stories by Alex Ott on Medium]]></description>
        <link>https://medium.com/@alexott_en?source=rss-74ef63c7f56e------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*KYp49TAoE-gIWx7Y.</url>
            <title>Stories by Alex Ott on Medium</title>
            <link>https://medium.com/@alexott_en?source=rss-74ef63c7f56e------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 02:33:37 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@alexott_en/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[On measuring MITRE ATT&CK coverage]]></title>
            <link>https://medium.com/@alexott_en/on-measuring-mitre-att-ck-coverage-e068b42bcc24?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/e068b42bcc24</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[lakewatch]]></category>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[mitre-attack-framework]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 15:30:31 GMT</pubDate>
            <atom:updated>2026-11-02T15:30:31.446Z</atom:updated>
            <content:encoded><![CDATA[<p>The detection coverage is a measure for known adversary techniques, behaviors, or attack lifecycle stages that a security operations center (SOC) can reliably identify and alert on with its deployed tools and telemetry. This measure is used to prioritize development of detection rules, performing threat hunts, etc. Coverage measurement and prioritization were common topics at recent <a href="https://na.eventscloud.com/website/93925/">ATT&amp;CKcon 7.0</a>.</p><p>Security teams often measure detection coverage by counting techniques in the <a href="https://attack.mitre.org/">MITRE ATT&amp;CK</a> matrix that have at least one detection rule tagged with a specific technique. This measure is easy to compute, but it doesn’t show whether the required telemetry exists or whether analysts work on generated alerts.</p><p>This post describes a specific coverage calculation model with three axes: visibility (telemetry), detection (rules), and response (alerts that analysts work on). I implemented that model as a set of Databricks jobs and a Databricks App (plus an optional AI/BI Dashboard) over <a href="https://docs.databricks.com/aws/en/lakewatch/">Lakewatch</a> and tested it in my workspace. The same model can be used with any SIEM that allows access to telemetry, detection rules, and alerts.</p><h3>Why a rule-tag count is not enough</h3><p>A rule tag is a statement by the rule author: “this rule is about technique T1110”. The tag can be correct, but the coverage number that you build only from tags has a few problems:</p><ul><li><strong>No data check.</strong> A rule over Windows process events is tagged with T1059.001 (PowerShell), but the endpoint telemetry was never onboarded, or it stopped arriving two months ago. The rule exists, runs, and finds nothing.</li><li><strong>No response check.</strong> The rule fires, but alerts produced by it stay open and unassigned. Such a rule provides little operational value.</li><li><strong>No prioritization.</strong> All techniques count the same. A rule for a technique that nobody uses has the same weight as a rule for T1078 (Valid Accounts), which is used by many ATT&amp;CK groups.</li></ul><p>In my workspace, the tag-only method reports 58 of 697 techniques and sub-techniques (8.3%) as covered. Under the three-axis definition used in this post, only 17 techniques (2.4%) are “Monitored” or “Well-covered”. The 8.3% number hides the work that teams need to do.</p><h3>Three axes and seven states</h3><p>The method grades each technique and sub-technique from 0 to 3 on three independent axes. For example, a technique can have current telemetry, but it has no rules. Its visibility score is positive, but its detection score is 0, and its state is “Deaf”.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/775/1*xp9O1YZbN0jFxxe7hHrs1w.png" /></figure><p>These scores give a state to a technique. The rules from the table below are evaluated from the top, and we stop after the first match:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/585/1*RWn0MZ-Aj8Un8NCG7fkkQQ.png" /></figure><p>The difference between the states is the main value of the method. Each state points to a different team and a different action: a “Blind” goes to data engineering, a “Deaf” goes to detection engineering, and a “Unactioned” goes to the SOC manager. When you calculate a percentage from a single source, you can’t perform such routing.</p><p>and each state maps to an action. Blind leads to “onboard a source”, Degraded to “fix data quality”, Deaf to “write a detection”, Silent to “verify firing and routing”, Unactioned to “assign or tune”, Monitored to “add depth”, Well-covered to “maintain”.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dKqClnR4aybphD1WJ-hMXw.png" /></figure><p>A “Silent” state can have multiple meanings: the technique doesn’t happen in your environment (good), or the rule is broken, or its output isn’t routed to analysts (bad). The current approach can’t distinguish between them. It only shows that there is no evidence that the path from the rule to the analyst works. To confirm it, run an adversary emulation (i.e., with <a href="https://github.com/redcanaryco/atomic-red-team">Atomic Red Team</a>) and check that an alert is generated.</p><p>Stale data also needs special handling. If one data source stops sending data for a day, all techniques that depend on it are marked as having degraded visibility. A technique with visibility 1 and a working rule keeps its state, but the app shows a “stale data” marker on the technique’s cell. Only a technique with stale data and no rule gets the “Degraded” state. As a result, a technique can be “Monitored” or “Well-covered” and still carry the stale-data warning.</p><h3>Results from the demo workspace</h3><p>I ran the coverage job on a workspace with Lakewatch v2. The workspace has 25 data sources, 164 detection rules (112 enabled, 157 with ATT&amp;CK tags), and ~3k security alerts in the last 30 days. Many rules are per-source instances of the same detection (one rule per parsed table), and the job counts each such group once, so there are 82 distinct detections. The absolute numbers are low, but it’s sufficient to demonstrate the scoring behavior.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/342/1*VRZlSzx8s5G_wRskxp4kng.png" /></figure><p>three-axis coverage (Monitored or Well-covered) is 17 of 697 (2.4%). The 41 tagged techniques that are not covered split into 23 Silent and 18 Unactioned.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BfjdlFOB7xMybBr8VZPIrw.png" /></figure><p>All numbers in this post count techniques and sub-techniques together. If you count only the 222 parent techniques, the tag-only method gives 33 (14.9%), and the three-axis method gives 10 (4.5%). The ratio is almost the same, but make sure that you use the same scope when you compare numbers from different reports.</p><p>The query that shows that data is very simple, because all scores are in one mitre_coverage table:</p><pre>FROM mitre_ref_v2.mitre_coverage<br>|&gt; AGGREGATE<br>     count(*) AS total,<br>     count_if(tagged_rule_count &gt; 0) AS tagged,<br>     count_if(technique_state IN (&#39;Monitored&#39;, &#39;Well-covered&#39;)) AS covered,<br>     count_if(tagged_rule_count &gt; 0 AND visibility_score = 0) AS tagged_blind,<br>     count_if(visibility_degraded) AS stale,<br>     ...</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/910/1*eGzGEyCDUFzQiROAMTVIZA.png" /></figure><p>The results show several gaps that a tag count does not show.</p><ul><li><strong>Blind is now a small group.</strong> When I first run the coverage job in this workspace, 327 techniques were “Blind” due to the missing data. After I onboarded more data sources, only 67 techniques are “Blind”, and 45 of them have no data component at all (see below). The number of “Deaf” techniques grew from 148 to 551: the new data made many techniques visible, but there are no rules for them yet. This is the expected next step: the gap moves from data engineering to detection engineering.</li><li><strong>Silent rules.</strong> 23 techniques have a solid rule but no alerts in 30 days, for example, T1053.005 (Scheduled Task), T1003.001 (LSASS Memory), and T1041 (Exfiltration Over C2 Channel). Each of these needs a test to prove that the rule can fire.</li><li><strong>Unactioned alerts.</strong> Of the 2,971 alerts, 1,165 are still open, 232 are assigned, and 1,234 have a classification (1,226 false positives and 8 true positives). Most of the resolved alerts come from triage and tuning sessions. The open alerts are not spread evenly: T1110 (Brute Force) has 78 alerts in the window, and nobody has assigned or classified any of them (45 are still open). A tag-only report presents this as strong coverage because the technique has 9 rules (6 of them tagged), although nobody is working the alerts.</li><li><strong>Noisy rules.</strong> T1033 (System Owner/User Discovery) and T1082 (System Information Discovery) are “Monitored” with response 3, because most of their 1,503 alerts are resolved. But 1,001 of these alerts are false positives. The response axis shows that analysts work the queue, and the false-positive count shows that the corresponding rules need tuning.</li><li><strong>Stale data.</strong> 21 techniques have visibility 1 because their only data sources stopped receiving new data, and all of them are in the “Degraded” state. In a production environment, investigate the stale-data warning first, because a current rule over stale data finds nothing.</li><li><strong>Empty tactics.</strong> Defense Impairment, the new tactic from v19, has 56 techniques and sub-techniques, and the demo workspace still has no rule for any of them. Impact and Discovery have rules for only 2 techniques each.</li></ul><p>The results also expose a scoring limitation. T1021 (Remote Services) is “Well-covered”, with 6 rules and 21 alerts, but 20 of the alerts are open and unassigned. The response score is 2 (triaged), because the rule for “triaged” requires that at least one alert is engaged. A ratio threshold (for example, at least 50% of the alerts engaged) gives a stricter result. Pick the thresholds for your SOC, and write them down next to the report.</p><p>The scoring model is independent of Lakewatch, so it could be implemented on any SIEM. The following sections show how I implemented each axis with Lakewatch v1 and v2.</p><h3>The reference data: ATT&amp;CK in Delta tables</h3><p>The first step is to load ATT&amp;CK into tables. MITRE publishes ATT&amp;CK as <a href="https://github.com/mitre-attack/attack-stix-data">STIX 2.1 bundles</a>, one file per version. A job loads the bundle for a selected version into Delta tables: tactics, techniques, groups, group-to-technique relationships, data components, and technique-to-data-component links. Pin the version: if the matrix changes under you, the coverage numbers change without a change in your environment.</p><p>The selection of a version matters a lot. ATT&amp;CK v18 (October 28, 2025) deprecated the x_mitre_data_sources field of techniques, and MITRE says that the field is accurate only up to the April 2025 release. Many tools and mappings use this field to find which telemetry can see a technique, and they give wrong results on v18 and later. The link now goes through the new detection model:</p><pre>technique &lt;-[detects]- detection strategy<br>detection strategy -&gt; x_mitre_analytic_refs -&gt; analytic<br>analytic -&gt; x_mitre_log_source_references[].x_mitre_data_component_ref<br>         -&gt; data component</pre><p>So a technique is linked to data components through all analytics of all detection strategies that detect it. The loader in the app does this walk and stores the result as a flat attack_technique_data_components table. The loader fails on versions older than 18.0 because they do not have this model.</p><p>detection strategy, which references analytics, which reference log sources with data components. On the telemetry side, a Lakewatch parsed table (v2) or an OCSF gold table (v1) is mapped to the same data components.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KxRAF_YOK1v6NEM9sFHDJA.png" /></figure><p>There are 2 other changes that you can see in the coverage numbers:</p><ul><li><strong>v19 (April 2026)</strong> split the Defense Evasion tactic into <strong>Stealth</strong> and <strong>Defense Impairment</strong>, so the Enterprise matrix has 15 tactics now. Any coverage report that was built on Defense Evasion must be rebuilt.</li><li><strong>v20 (October 13, 2026)</strong> added techniques for attacks on AI systems, for example, Direct Prompt Injection and AI-Enabled Exfiltration. Enterprise v20 has 227 techniques, 479 sub-techniques, 703 detection strategies, and 1,770 analytics.</li></ul><p>The numbers in this post use ATT&amp;CK v19.1, which was current when I built the reference tables. To move to v20, we just need to update the version and the STIX URL in the configuration file, and re-run the reference and coverage jobs.</p><p>When the data components are known, we can see why some techniques cannot be measured with telemetry at all. In v19.1, 45 techniques have no data component: 30 in Reconnaissance and 15 in Resource Development. They happen outside of your environment, so no log source can see them. The app marks them “Blind”, but in a report you should separate them from real gaps.</p><h3>Visibility: from parsed tables to data components</h3><p>The visibility axis needs a mapping from your telemetry to ATT&amp;CK data components. This is the most important part of the method, and it requires manual work. ATT&amp;CK says which data components can see a technique, but it does not know your log sources.</p><p>In Lakewatch v1, the telemetry is normalized to OCSF, so the mapping is from OCSF classes (for example, Process Activity) to data components (for example, Process Creation). In Lakewatch v2, there is no OCSF normalization yet: each source has its own parsed table with the source columns, plus a shared lw_observables column for entity pivots. So the mapping for Lakewatch v2 is per parser. The coverage job finds the live parsed tables through the Lakewatch datasources API: each datasource lists its parsers and their target tables. Then it computes quality signals per parser.</p><p>A source is healthy (status 2) if it has rows, its newest event is inside the freshness SLA (48 hours in my configuration), and all required fields are populated above a threshold. If one of these checks fails, the source is degraded (status 1). A data component takes the best status of all sources mapped to it, and the technique visibility score comes from the statuses of its data components (0 if none is present, 3 if 2 or more are healthy). This method still isn’t perfect as it doesn’t take into account the all potential data sources that should provide telemetry. I.e., we have firewall logs only for AWS, but don’t have them for Azure and on-premise.</p><h3>Detection: rules, tags, and external findings</h3><p>The detection axis comes from the rules. In Lakewatch v2, the rules API returns each rule including the mitre attribute that is the list of technique and sub-technique IDs, its SQL query, and the schedule. The job uses 3 kinds of rule-to-technique mappings:</p><ul><li><strong>Tagged.</strong> The mitre list of the rule. This is high confidence.</li><li><strong>Inferred.</strong> For a rule without tags, an LLM reads the rule name, description, and query, and try to infer technique IDs. The job checks the IDs against the pinned ATT&amp;CK version, and caches the result by a hash of the rule content. An inferred mapping gives detection score 1 at most.</li><li><strong>External.</strong> Products such as EDR or cloud security services already map their findings to ATT&amp;CK. In Lakewatch v2, a parser that declares the mitre_attack_technique observable puts these IDs into lw_observables.mitre_attack_technique_ids. The job finds these parsers from their specs and counts the technique IDs that appear in their tables.</li></ul><p>Disabled rules are counted separately, and they don’t contribute to the score. A disabled rule is a common way to get a “covered” technique on a slide without any detection in production.</p><h3>Response: Do analysts act on the alerts?</h3><p>The response axis is the one that a rule-tag count can’t give you. In Lakewatch v2, the alerts API returns security alerts with their state, assignee, and classification (true/benign/false positive). Each alert refers to the rule that created it, so the job joins alerts to techniques through the same rule-to-technique index that the detection axis uses. This way both axes use the same mappings, including the inferred ones.</p><p>For each technique, the job looks at alerts from the last 30 days:</p><ul><li>No alerts: response 0 (silent).</li><li>Alerts exist, but none is assigned, in progress, or classified: response 1 (ignored).</li><li>At least one alert is engaged: response 2 (triaged).</li><li>At least half of the alerts are resolved with a classification: response 3 (worked).</li></ul><p>The false-positive count is kept too, because a rule that produces many false positives may require tuning even if it fires.</p><p>There are 2 v2-specific points:</p><ul><li>An alert in v2 can group several signals of a rule, so the counts are alert counts, not signal counts.</li><li>The v2 alerts API can filter only on the alert creation time. An alert that was created before the window and analyst worked inside that window isn’t counted.</li></ul><h3>Threat-informed ranking of the gaps</h3><p>A list of 551 “Deaf” techniques is not a plan. To create it, the method weights each technique by its relevance to adversaries. The default weight is based on the number of ATT&amp;CK groups that use the technique (from the group-to-technique relationships in STIX). You can also select a threat profile, a list of groups that matter for your industry, and the weights then come only from these groups.</p><p>With the weights, we rank the actions, not the techniques. For “Deaf” techniques, the question is “which data component makes the most weighted techniques visible?”:</p><pre>FROM mitre_ref_v2.mitre_coverage AS c<br>|&gt; WHERE technique_state = &#39;Blind&#39; AND size(data_components) &gt; 0<br>|&gt; JOIN mitre_ref_v2.threat_technique_weights AS w<br>     ON w.technique_id = c.technique_id AND w.profile_id = &#39;all-groups&#39;<br>|&gt; SELECT explode(c.data_components) AS component, c.technique_id, w.weight<br>|&gt; AGGREGATE count(DISTINCT technique_id) AS techniques,<br>     round(sum(weight), 2) AS weighted<br>   GROUP BY component<br>|&gt; ORDER BY weighted DESC<br>|&gt; LIMIT 5</pre><p>In the first run in my workspace, the answer was clear:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/329/1*YsrOfdvEJf_sxBhhHiiz_w.png" /></figure><p>The plan was “onboard endpoint telemetry with process creation events”. I did it, and the same query now returns only a short tail (Response Content with 16 techniques is at the top).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/336/1*3J3RjMr1wAd0L_FFbrmPHw.png" /></figure><p>Now most of the gaps are “Deaf”, so the same weights give a list of detections to write:</p><pre>FROM mitre_ref_v2.mitre_coverage AS c<br>|&gt; WHERE technique_state = &#39;Deaf&#39;<br>|&gt; JOIN mitre_ref_v2.threat_technique_weights AS w<br>     ON w.technique_id = c.technique_id AND w.profile_id = &#39;all-groups&#39;<br>|&gt; SELECT c.technique_id, c.technique_name, round(w.weight, 2) AS weight<br>|&gt; ORDER BY weight DESC<br>|&gt; LIMIT 5</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/353/1*03S-j_Rxb2nn0Quwp0dHFw.png" /></figure><p>For “Silent” and “Unactioned” techniques, the same ranking gives a list of rules to test or tune.</p><p>The ranking is deterministic: it is SQL over the coverage and weight tables. This is important when you report the plan to management, because the same input always gives the same result.</p><h3>How this method is implemented</h3><p>The implementation has 2 Lakeflow jobs and 3 consumers, and all of them are deployed using <a href="https://docs.databricks.com/aws/en/dev-tools/bundles/">Declarative Automation Bundle</a>. The jobs write Delta tables and consumers read these tables. Here is the full list of components:</p><ul><li><strong>Reference job.</strong> Loads the pinned ATT&amp;CK STIX bundle, the curated mappings (OCSF for v1, parsers for v2), and the threat profiles into Delta tables. It runs only on initial deployment, or when the ATT&amp;CK version or a mapping changes.</li><li><strong>Coverage job.</strong> Collects the inputs (data quality stats, rules, external findings, and alerts), computes the three scores and the state, etc. and writes the mitre_coverage table plus a historical snapshot so we can track trends. It runs on a daily schedule by default.</li><li><strong>App.</strong> A Databricks app with a scorecard (including the tag-only versus three-axis contrast), the ATT&amp;CK matrix colored by state, a gap queue for detection engineers, an <a href="https://mitre-attack.github.io/attack-navigator/">ATT&amp;CK Navigator</a> layer export, and an embedded Genie agent for free-form questions over the coverage tables. It’s also possible to draft with a LLM a first version of a detection query for a specific technique, but it’s very generic right now.</li><li><strong>AI/BI dashboard.</strong> Counters for each axis, the tag-only versus three-axis contrast, techniques by state, and the ATT&amp;CK matrix (I wrote about it in the <a href="https://alexott.blogspot.com/2026/08/using-custom-visualizations-in.html">blog post about custom visualizations</a>). A scope filter selects if the counters will include sub-techniques or not. Use it when you don’t want to run an app to save costs (but you won’t get prioritization queue in this case).</li><li><strong>Genie agent.</strong> Answers free-form questions over the coverage and reference tables. You can use it in the app or directly in the Databricks UI.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MiW7MuPxCIPzVX6_LCYFbg.png" /></figure><p>When deployed, we can see the result as a matrix view in the app (or in the dashboard):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ewMal9ii4AfkDg7IlV2eoQ.png" /></figure><p>Lakewatch v1 and v2 use different sources for each axis, but everything after the data collection is done with the same code:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*p6md3yppuX_OfY9M6wLeXQ.png" /></figure><h3>Asking questions about the coverage with Genie</h3><p>The app &amp; dashboard provide answers to the fixed set of questions that I selected, but each team has its own questions. For them, the bundle deploys a Genie agent over the coverage (mitre_coverage and mitre_coverage_snapshots) and the reference tables (techniques, tactics, groups, data components, threat profiles, etc.). This agent allows users to ask in plain English and get the result back (together with the SQL query that was generated).</p><p>I asked the agent deployed in my workspace some typical questions from different roles. All results below are from the same run as the numbers above.</p><p><strong>SOC manager: what changed?</strong> The snapshot table keeps one copy of the coverage per day, so trend questions are easy. The question “How did the number of techniques in each technique state change between the first and the latest coverage snapshot?” gave this result:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/382/1*15C2Lh9jD37SeFKdnmHvqg.png" /></figure><p>Genie also wrote a short summary: techniques moved out of “Blind” and “Degraded”, and most of them moved into “Deaf”. This is the before/after picture from the results section, and you can use it in a status report without extra work.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VoBgy8UGvv2Bui7ZMq8UCg.png" /></figure><p>A SOC manager does not need to ask this question every time. In <a href="https://docs.databricks.com/aws/en/genie-one/chat">Genie One</a> you can run a request as a scheduled task: at the selected time, it will run the request, post the result in a chat thread, and send an email with the result and a PDF copy. You can create the task from the chat with a request like “Every Monday at 8:00, show how the number of techniques in each technique state changed since last week, and list the techniques that moved to Silent or Unactioned”, or just select the specific time in the UI. With a daily or weekly task, the coverage trend and the alert queue come to the SOC manager by email, and nobody has to remember to run them manually.</p><p><strong>SOC manager: which rules make noise?</strong> The question “Which techniques have the most false positive alerts, and what share of their alerts are false positives?” returned T1033 and T1082 at the top, with 1,001 false positives of 1,503 alerts (66.6%), and then T1078 (Valid Accounts) with 118 of 235 (50.2%). These are the first candidates for tuning.</p><p><strong>Detection engineer: what to write next?</strong> For “Deaf” techniques, the data is already there, so the question is which detection to write first. The question “Which Deaf techniques are used by the most threat groups? Show the top 10 with the data components we already have for them.” gave a ranked list that also shows the available data:</p><p>technique_id technique_name groups present_data_components T1083 File and Directory Discovery 51 Command Execution, File Access, File Creation, Process Creation T1204.001 Malicious Link 49 File Creation, Network Connection Creation, Network Traffic Content, Process Creation T1070.004 File Deletion 47 Command Execution, File Access, File Deletion, File Modification, Process Creation T1059.005 Visual Basic 46 Command Execution, Module Load, Process Creation, Script Execution T1566.002 Spearphishing Link 46 Application Log Content, Network Connection Creation, …</p><p>The order is the same as in the weighted ranking above, and the last column tells the engineer which parsed tables to start with.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wYBJn9UWyRrskPjL9VFldA.png" /></figure><p><strong>Threat intelligence: what matters for our adversaries?</strong> The question “Which techniques should we prioritize for the ransomware threat profile? Show weight and current state.” returned the 63 techniques of the profile that are in a gap state: 42 Deaf, 9 Unactioned, 6 Silent, 4 Blind, and 2 Degraded. It left out the 3 Monitored and Well-covered techniques. Genie also noticed that all techniques have the same weight (1.0), so the weight does not help to order them. The reason is that the demo ransomware profile has only one group. A real profile needs several groups, so that the weights can separate the techniques.</p><p><strong>Report for management: which tactics are weak?</strong> The question “Which tactics have the weakest coverage? Show per tactic the number of techniques, how many have a rule, and how many are Monitored or Well-covered.” gave the same picture as the results section: Defense Impairment (56 techniques), Resource Development (50), and Reconnaissance (46) have no rules, and Impact, Exfiltration, and Execution have rules, but no covered techniques.</p><h3>Limitations</h3><p>The presented method gives a better picture than a tag count, but it has limits that you may need to include in your reports if you use this approach:</p><ul><li><strong>A data component is coarse.</strong> “Process Creation” is present if any process events arrive, but a detection for T1059.001 may need the full command line from all Windows hosts. Visibility 2 means “the right kind of data exists”, not “the data is enough for every analytic”.</li><li><strong>The mapping is manual work.</strong> The telemetry-to-data component mapping decides the visibility axis. Review it, version it, and treat the LLM fallback as a list of suggestions.</li><li><strong>A tag is not a test.</strong> The detection axis still trusts the rule author. To measure if a rule detects a technique, you need adversary emulation or replay of known attack data, and you can feed the results back into the detection score.</li><li><strong>Thresholds are choices.</strong> The freshness SLA, the completeness threshold, the response window, and the “triaged” rule change the result. The T1021 example above shows that.</li><li><strong>Techniques outside your environment.</strong> Reconnaissance and Resource Development aren’t visible in your telemetry. Report them separately, or cover them based on threat intelligence.</li></ul><h3>Conclusion</h3><p>A coverage number that counts tagged rules is easy to compute, but it mixes together three different problems: missing data, missing rules, and alerts that nobody handles. When you measure the three axes separately, every gap gets a state, and every state has an owner and an action. With threat weights on top, the list of gaps becomes a ranked plan, for example, “onboard process telemetry first”, and after that, “write a detection for file and directory discovery”.</p><p>The method does not depend on a specific SIEM. You just need ATT&amp;CK in tables, a mapping from your log sources to data components, access to the data quality of these sources, a list of rules with their ATT&amp;CK mappings, and a list of alerts with their states. In Lakewatch, all of this is available via Lakewatch and Databricks REST API, so the entire pipeline is just a few Lakeflow jobs and SQL queries.</p><h3>References</h3><ul><li><a href="https://na.eventscloud.com/website/93925/">ATT&amp;CKcon 7.0 agenda</a></li><li><a href="https://github.com/mitre-attack/attack-stix-data">MITRE ATT&amp;CK STIX data</a></li><li><a href="https://attack.mitre.org/resources/updates/updates-october-2026/">ATT&amp;CK v20 release notes (October 2026)</a></li><li><a href="https://attack.mitre.org/resources/updates/updates-april-2026/">ATT&amp;CK v19 release notes (April 2026)</a></li><li><a href="https://docs.databricks.com/aws/en/lakewatch/">Lakewatch documentation</a></li><li><a href="https://mitre-attack.github.io/attack-navigator/">ATT&amp;CK Navigator</a></li></ul><p><a href="https://alexott.blogspot.com/2026/11/on-measuring-mitre-att-coverage.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e068b42bcc24" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Scoring and clustering domain indicators with the Common Crawl web graph]]></title>
            <link>https://medium.com/@alexott_en/scoring-and-clustering-domain-indicators-with-the-common-crawl-web-graph-a242fac562c7?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/a242fac562c7</guid>
            <category><![CDATA[lakewatch]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[graph-analytics]]></category>
            <category><![CDATA[databricks]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 08:23:44 GMT</pubDate>
            <atom:updated>2026-10-26T08:23:44.423Z</atom:updated>
            <content:encoded><![CDATA[<p>A single domain indicator gives an analyst little context. A proxy log or a threat feed produces a domain, but it does not show whether the domain has an established presence on the web or which other domains are in the same link neighborhood.</p><p>This post uses the Common Crawl web graph to add that context. It tests graph presence, in-degree, links to feed indicators, connected components, a popular-site baseline, and a feed of newly observed domains. The results also show an important limit: the graph is most useful for linked web ecosystems and has little coverage of new or link-isolated domains.</p><p>This is a follow-up to <a href="https://alexott.blogspot.com/2026/09/graph-analytics-for-cybersecurity-data.html">Graph analytics for cybersecurity data in the lakehouse</a>, which introduced GraphFrames on OCSF data and the idea that the security lakehouse is already a graph. Here the graph is external: the web itself.</p><h3>About the Common Crawl web graph</h3><p><a href="https://commoncrawl.org/">The Common Crawl project</a> publishes a <a href="https://commoncrawl.org/web-graphs">web graph</a> a few times a year. It has two levels. In the host graph the vertices are fully qualified host names, such as mail.example.com. In the domain graph the vertices are pay-level domain names such as example.com. The edges are hyperlinks: a page on one vertex links to a page on another vertex, aggregated to the vertex level.</p><p>This is a link-reputation graph, not an infrastructure graph. It does not record name servers, shared hosting, WHOIS registrants, or TLS certificates. Those attributes need a different data source and a different graph (maybe it will be a good topic for another post). The web graph answers one kind of question: how a domain sits in the web neighborhood, measured by who links to it and who it links to.</p><p>Two limits follow from how the data is produced, and both matter for security use:</p><ul><li><strong>Freshness.</strong> A crawl is weeks to months old, so the graph is weakest exactly where security cares most: short-lived phishing and fast-flux command-and-control domains that are registered and burned before a crawl sees them. The freshness analysis measures this gap and closes part of it.</li><li><strong>Coverage bias.</strong> A domain can be missing because it is new, private, regional, or simply not linked. Absence is a signal to check, not proof of anything.</li></ul><h3>The data on the lakehouse</h3><p>The graph is loaded into Unity Catalog as four tables built from the May, June, and July 2026 Common Crawl releases:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/403/1*UZD3ulONm9IkwVCT2mSzHQ.png" /></figure><p>The vertex tables store the name in a reversed form. For example, rev_domain holds com.blogspot, which is SURT (<a href="http://crawler.archive.org/articles/user_manual/glossary.html">Sort-friendly URI Reordering Transform</a>) form of blogspot.com. To join the graph against a domain from telemetry, rebuild the forward name by reversing the dot-separated labels:</p><pre>from pyspark.sql import functions as F<br><br>def fwd(col):                       # com.blogspot -&gt; blogspot.com<br>    return F.array_join(F.reverse(F.split(F.col(col), r&quot;\.&quot;)), &quot;.&quot;)<br>domains = (<br>    spark.table(&quot;cybersecurity.graph.domain_vertices_2026_may_jun_jul&quot;)<br>    .select(&quot;id&quot;, &quot;num_hosts&quot;, fwd(&quot;rev_domain&quot;).alias(&quot;domain&quot;))<br>)</pre><p>The tables do not carry precomputed centrality or PageRank columns, so any reputation score should be be explicitly computed. The domain vertices have one useful pre-built number, num_hosts - the count of identified hosts under that domain.</p><p>We use three more datasets in the later sections. The threat-intelligence feed threatintel.iocs is the known-bad source (described in a <a href="https://alexott.blogspot.com/2022/10/ingesting-indicators-of-compromise-with.html">separate blog post</a>). It already carries a psl.registered_domain column, so the pay-level domain is pre-computed. The popular-site baseline is datasets.top_sites, containing the Cisco Umbrella and Tranco top-1,000,000 lists (plus historical Alexa list). The freshness feed is datasets.new_sites, a daily feed of newly observed domains (only limited portion, like 100k/day).</p><h3>Scale affects the method: scope your data before you compute</h3><p>The domain graph has 2.77 billion edges and the host graph has 3.67 billion. A global PageRank or a global connected-components run over a graph this size is expensive and really not necessary for the tasks in this post. Every task fits naturally:</p><ul><li>Reputation and propagation only need the edges that touch a limited set of domains: the domains seen in your data, or the known-bad seeds.</li><li>Clustering only needs the edges that run between the known-bad domains.</li></ul><p>So we can do this as follows: take the small set of domains we care about, resolve them to vertex ids, and touch the large edge table once with a broadcast join that keeps only the rows for those ids. This runs on serverless compute and keeps the cost proportional to the question, not to the graph.</p><p>All the graph algorithms here were run on serverless Databricks using an unreleased pure-PySpark GraphFrames implementation, so you will need to use classic compute if you want to try this on your data.</p><h3>Reputation as a feature</h3><p>The first use case is enrichment. For every domain that matters to us, we want a cheap score that says how established the domain is on the web. Two numbers from the graph are enough to start: whether the domain is present at all, and its in-degree, the number of distinct domains that link to it.</p><p>The set of domains we care about comes from two sources. The first is the known-bad domains from the threat-intelligence table iocs (the domain, hostname, and URL-host indicators), reduced to their pay-level domain with the pre-computed psl.registered_domain column. The second is the domains observed in DNS telemetry. On this workspace that telemetry is the DNS queries from <a href="https://www.malware-traffic-analysis.net/">malware-traffic-analysis.net</a> capture sessions, ingested as OCSF gold, with the synthetic demo data excluded (see <a href="https://alexott.blogspot.com/2026/09/analyzing-labeled-malware-traffic-with.html">blog post</a> for details). That observed set is therefore a mix: the malicious domains a captured infection contacted, plus the ordinary domains any host contacts, such as content-delivery networks, update servers, and analytics endpoints.</p><p>Presence in the graph is an inner join against the vertex table. In-degree needs the large edge table, so we scope it: resolve the target domains to their vertex ids, broadcast that small set, and touch the 2.77 billion edges once, keeping only edges that point to a target id.</p><pre>from pyspark.sql.functions import broadcast<br><br>present_ids = targets.join(domains, &quot;domain&quot;).select(&quot;id&quot;).distinct()<br>in_degree = (<br>    spark.table(&quot;cybersecurity.graph.domain_edges_2026_may_jun_jul&quot;)<br>    .join(broadcast(present_ids), F.col(&quot;to_id&quot;) == F.col(&quot;id&quot;))<br>    .groupBy(&quot;to_id&quot;).agg(F.count(&quot;*&quot;).alias(&quot;in_degree&quot;))<br>)</pre><p>The results show that the web graph is a sparse reference: most domains are not in it at all. Only 12.4% of the known-bad domains and 29.9% of the domains seen in the DNS table are present. Among the domains that are present, the ones seen in the capture traffic are more linked (median in-degree 7) than the known-bad ones (median in-degree 2), because the observed set is dominated by the ordinary, well-established domains a host contacts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/613/1*b7L1P_kslJfSJ3jG0nqbDQ.png" /></figure><p>From presence and in-degree we derive one experimental enrichment column, web_reputation, with four values: absent (not observed in this graph snapshot), isolated (present but in-degree 0), weak (in-degree below 5), and established. The threshold of 5 is a working threshold for this analysis, not a general boundary between benign and malicious domains.</p><pre>enriched = scored.withColumn(<br>    &quot;web_reputation&quot;,<br>    F.when(~F.col(&quot;present&quot;), &quot;absent&quot;)<br>     .when(F.col(&quot;in_degree&quot;) == 0, &quot;isolated&quot;)<br>     .when(F.col(&quot;in_degree&quot;) &lt; 5, &quot;weak&quot;)<br>     .otherwise(&quot;established&quot;),<br>)</pre><p>Consider this as a soft feature, not a verdict, and read it in two directions. Most known-bad domains, 87.6%, are absent: they have no observed history in this graph snapshot. This is consistent with short-lived phishing and command-and-control names. A sample from this group is typical: nicholaswilliams.online, 2344bet-online.top, spribegaming56.click, appstoreautomation.site, com-icjo.xin. A domain in your telemetry with an absent or isolated reputation is worth a closer look, because the crawl did not observe a linked presence.</p><p>The other direction is more useful than it first appears. Only 4.4% of the known-bad domains are established, and when we list them by in-degree the top entries are googleusercontent.com, weebly.com, tinyurl.com, squarespace.com, and sharepoint.com. These are shared hosting, website builders, link shorteners, and document services. A domain-level feed entry can identify abuse on one part of such a service without showing that the provider or its other tenants are malicious. A high web_reputation is therefore a reason to review the indicator context and possibly down-weight it, rather than assume that the whole domain is attacker-controlled. The pattern is clearest in the overlap class: among domains that are both observed in traffic and flagged bad, 25.5% are established, because active campaigns lean on established and compromised services.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mhr2EB7YGMqdntm4GIPC5g.png" /></figure><p>The important caveat is that absence does not prove maliciousness. A missing domain can be new, regional, internal, or simply unlinked. The feature narrows attention — it does not make a decision.</p><h3>Propagate proximity from feed indicators</h3><p>In-degree measures how established a domain is, which is a general signal. A more direct security signal is proximity to domains in a threat-intelligence feed. A site that links to several feed indicators can be a useful review candidate, even if it is not itself in the feed. Previously I worked with a web-reputation system built on this idea. It started from a set of known-bad servers and propagated a bad rating to the hosts that connected to them, iterated over a large web graph.</p><p>The full method is personalized PageRank seeded on the indicator set, sometimes called BadRank, the inverse of TrustRank. Here I compute the first and dominant propagation hop, which is cheap and already useful: for every domain, count how many distinct feed indicators it links to. The seed set contains feed domains that are present in the graph and have an in-degree below 500. This experimental threshold excludes high in-degree shared services, such as googleusercontent.com and squarespace.com, whose labels would otherwise spread to a large part of the graph.</p><pre>bad_seed = scored.where(<br>    &quot;label LIKE &#39;%known_bad%&#39; AND present AND in_degree &lt; 500&quot;<br>).select(&quot;id&quot;)<br><br>link_to_bad = (<br>    spark.table(&quot;cybersecurity.graph.domain_edges_2026_may_jun_jul&quot;)<br>    .join(broadcast(bad_seed.withColumnRenamed(&quot;id&quot;, &quot;to_id&quot;)), &quot;to_id&quot;)<br>    .groupBy(&quot;from_id&quot;).agg(F.countDistinct(&quot;to_id&quot;).alias(&quot;bad_links&quot;))<br>)</pre><p>The seed set is 23,918 domains, and 205,463 domains link to at least one of them. The useful part is the new candidates: 41,905 domains that are not in the feed link to at least two known-bad domains, and 21,937 link to at least three.</p><p>Ranked by the number of known-bad links, the top new candidates split into three groups. The first is search-engine-optimization and backlink spam: backlinks.sbs (587 known-bad links), worldbusinesspromote.com, linksnatcher.art, indexaward.com, and domainanalysis.org. These are link-farm domains that exist to link between sites, so a hyperlink graph is well suited to finding them. The second is a shared-abuse service: freemyip.com (506 known-bad links), a dynamic-DNS provider that many unrelated malicious domains use. The third is a problem: popular legitimate domains that many bad sites link to, such as blogspot.com (1,440 known-bad links) and wikipedia.org (458). A domain can score high because it is inside a bad neighborhood or because it is a common link target, and the one-sided count cannot tell them apart. The popular-site baseline adds the missing half of the score.</p><h3>Measure how feed indicators cluster</h3><p>Propagation and expansion both assume that feed domains connect to each other. To test that directly, build the induced subgraph on the same 23,918 seed domains and keep only the edges where both endpoints are in the feed. connectedComponents then shows how the indicators group.</p><pre>sg = GraphFrame(bad_vertices, bad_edges)<br>components = sg.connectedComponents()</pre><p>The result sets a realistic expectation. Of the 23,918 known-bad domains in the graph, only 2,310 (about 9.7%), link to another known-bad domain at all, over 4,791 bad-to-bad links. The rest are link-isolated: they neither link to nor are linked from other feed indicators. This is consistent with command-and-control infrastructure that has no reason to publish links, although the graph alone cannot establish why each domain is isolated. Web-graph expansion cannot help with those.</p><p>The connected domains form 452 components. The largest holds 367 domains, and the next ones fall away to 292, 232, 42, and 26. The largest component is not one campaign. Its most connected vertices are ddns.us, changeip.us, dnsfailover.net, and ocry.com, the free dynamic-DNS domains of a single provider that many unrelated malicious domains use, so a shared abuse service bridges otherwise separate groups. This is the same collapse that popular hubs cause in the reputation analysis, one level down. The mid-size components are the more useful output: tight groups of feed indicators that link to each other and are worth reviewing together. One 26-domain component, for example, contains related news-style sites (newshour.site, mmnewsdaily.store, dailynews22.online, khnews777.online), and a 232-domain component is a set of machine-generated names on a shared template (advancedresearchdomain.com, advancedupskillinghub.com, advancedxplorationstation.com).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gY_gkRndN7w2kjxoXWGFXA.png" /></figure><h3>Add a popular-site baseline</h3><p>The first propagation step scored a domain by how many feed indicators it links to, and that one-sided count put three different things at the top of the same list: linked indicator neighborhoods, link farms, and popular sites that everyone links to. The graph also shows which domains are popular, and that information is the other half of the score. The popular-site baseline is top_sites, the Cisco Umbrella and Tranco top-1,000,000 lists. Reduced to pay-level domains and kept to the ones present in the graph, it is 977,171 popular domains. Popularity does not prove that a domain is benign, but it helps identify hubs and shared services that need different treatment from small, dedicated domains.</p><p>The baseline supports three related checks.</p><h4>Popular-site membership</h4><p>The first is membership. 8,514 of the 41,905 propagation candidates are themselves on the top-sites list, including mex.com, blogspot.com, wikipedia.org, and the dynamic-DNS provider freemyip.com. A candidate that is itself a popular site is more likely to be a hub or shared service. This membership is a reason to inspect and possibly down-weight the result, not proof that the domain is benign.</p><h4>Two-sided link score</h4><p>A two-sided score subtracts exposure to the popularity baseline from exposure to feed indicators, with each side normalized by the size of its seed set:</p><pre>net = F.col(&quot;bad_links&quot;) / n_bad_seed - F.col(&quot;good_links&quot;) / n_good_seed</pre><p>This is the one-hop form of two-sided label propagation: links to the popularity baseline offset links to feed indicators. It demotes the popular link targets that topped the raw ranking. Rounded to two decimal places, blogspot.com falls to a net score of -0.26, backlinks.sbs to -0.22, and worldbusinesspromote.com to -0.18, because each of them links to far more popular domains than feed indicators.</p><h4>Fraction of links to feed indicators</h4><p>Raw link counts still reward domains with many outbound links, because a link farm with millions of out-links will touch many feed indicators by volume alone. The better signal is the fraction of a domain’s links that go to feed indicators, which needs the total out-degree:</p><pre>cand = cand.withColumn(&quot;bad_frac&quot;, F.col(&quot;bad_links&quot;) / F.col(&quot;out_degree&quot;))</pre><p>By this measure the hubs and farms collapse to near zero: blogspot.com has a bad fraction of 0.0003 and backlinks.sbs 0.0007, because they link to everything. The high-fraction tail is a different population: machine-generated junk such as 031230708.xyz (bad fraction 0.80), and small, likely-compromised legitimate sites such as knva.org and mipsm.org, where the few outbound links point mostly at known-bad domains. A high bad fraction on a domain with only a handful of links is weak evidence. The ranking below uses 10 links to feed indicators as a working minimum.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zMOIfz3z1kBEV-17pCnhcQ.png" /></figure><h3>Add a freshness feed</h3><p>The graph’s main limit, as was mentioned at the beginning of the post, is freshness: it can miss new and short-lived domains, which is where security cares most. Two more datasets let us measure that gap and close part of it.</p><p>The first is WHOIS registration dates. On this workspace the WHOIS data covers mostly established and popular domains, so it says little about the feed: only 482 of the 170,961 known-bad domains that are absent from the graph have a WHOIS record at all. In this dataset, WHOIS and the link graph mostly cover established domains and provide little additional coverage for this feed.</p><p>The second is new_sites, a daily feed of newly-observed domains, 133.7 million of them from 2021 to September 2026. This is the freshness layer the graph lacks. It is a partial feed: the free service used here reports about 70,000 new domains per day, which is a fraction of all registrations, so its coverage is a lower bound and a complete feed of newly-registered domains would recover more. Even with it, measured against the known-bad feed, the two sources barely overlap and so they add up well: the graph contains 12.4% of the known-bad domains, new_sites contains 30.8%, and together they reach 39.3%. The freshness feed recovers 52,468 known-bad domains that the graph never saw.</p><p>The comparison also shows an association with age. Among the feed domains that appear in new_sites, the ones absent from the graph are measurably newer than the ones present: a median first-seen age of 572 days against 824 days, and 83.4% first appeared since 2024 against 59.2%. This supports freshness as one reason for missing coverage, but it does not explain every absent domain.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SPHBQQY5MV7s0Yi0fMUY7w.png" /></figure><p>This gives a two-dimensional triage that neither source gives alone. Combine the graph reputation with the freshness flag: a domain that is absent or weak in the graph and also recently seen in new_sites has no observed link history and is relatively new, which is the highest-priority profile. A domain that is established and not recently seen is the opposite and is a lower-priority profile. It can still represent abuse of a legitimate service.</p><h3>When this is useful</h3><p>The web graph is a reputation and triage layer, not a detection engine. Presence and in-degree give a cheap measure of how established a domain is. Propagation from known-bad seeds gives a proximity-to-bad score that surfaces new candidates, and it is strongest on cross-linked ecosystems such as search-engine and affiliate spam. Both are features for ranking and prioritizing domains, and both distort on popular domains, which is why the popular-site baseline and the bad-fraction normalization matter: they reduce the influence of popular link targets.</p><p>The main limit is structural. Most feed domains are absent from the graph or link-isolated within it. Some are new or short-lived; others can be missing because of crawl coverage or because they do not publish links. The freshness feed covers part of that gap. Combined with the graph, it more than triples feed coverage, but a large share of the feed is still invisible to both. For those domains, other data is needed. The infrastructure graph of shared name servers, hosting, and certificates is a different data source and a separate topic.</p><p>One extension remains natural. The one-hop propagation and one-hop offset can both be replaced with full iterated propagation, closer to the production BadRank and TrustRank systems that score web reputation this way. This would let a bad or good rating travel more than a single link.</p><p>All of the results above were produced on Databricks with graphframes and plain Spark joins over the graph tables. The source code for Common Crawl ingestion and code for graph analysis is in the <a href="https://github.com/alexott/databricks-cybersecurity-playground/tree/main/web-graph-analysis">repository</a>.</p><p><a href="https://alexott.blogspot.com/2026/10/scoring-and-clustering-domain.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a242fac562c7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Databricks SQL: Flexible time windows for security analytics with time_bucket]]></title>
            <link>https://medium.com/@alexott_en/databricks-sql-flexible-time-windows-for-security-analytics-with-time-bucket-08e11d28997e?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/08e11d28997e</guid>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Thu, 22 Oct 2026 07:34:07 GMT</pubDate>
            <atom:updated>2026-10-22T07:34:07.785Z</atom:updated>
            <content:encoded><![CDATA[<p>Time windows are heavily used in security analytics. A brute-force rule counts failed logins in 5 minutes, a beaconing hunt checks whether a host talks to the same destination every hour, and a SOC dashboard shows alerts per 15 minutes, per hour, per day, or per week. All of these need to perform the same operation: take the timestamp of an event and map it to the start of a specific time window.</p><p>Databricks SQL now has a function for this operation: <a href="https://docs.databricks.com/aws/en/sql/language-manual/functions/time_bucket">time_bucket</a>. This post continues the series about new Databricks SQL features that are useful for cybersecurity. In this post, we&#39;ll look at how the time bucketing was done before, what changes with the introduction of time_bucket, and how it will help you to migrate queries/rules from other SIEMs. All queries in this post were run on a serverless Databricks SQL warehouse over the OCSF gold tables in a Lakewatch workspace.</p><h3>How we bucketed time before</h3><p>Before time_bucket was added, there were three common approaches, and each of them has a problem.</p><p>The first one is to use date_trunc function. It works well for whole units (minute, hour, day, week, month), but there is no way to ask for 5, 10, or 15 minutes. And if you try, the query does not fail - an unknown unit will just return NULL:</p><pre>SELECT date_trunc(&#39;15 minutes&#39;, TIMESTAMP &#39;2026-10-16 10:07:00&#39;);<br>-- NULL</pre><p>When you do this in an aggregation, this is easy to miss: all rows will just go into a single NULL bucket.</p><p>The second approach is to perform arithmetic operations with Unix seconds. This is the most common pattern in existing queries, and LLM-based translations of KQL’s bin(TimeGenerated, 5m) often use it:</p><pre>-- returns a STRING, not a TIMESTAMP<br>from_unixtime(unix_timestamp(time) - unix_timestamp(time) % 300)<br>-- returns a TIMESTAMP, but is hard to read and easy to get wrong<br>timestamp_seconds(floor(unix_seconds(time) / 300) * 300)</pre><p>The first query returns a string, so you need to do one more cast before you can work with it. The second form is correct, but the bucket size is hidden in the arithmetic. For buckets that are not aligned to the epoch (shifts, fiscal quarters, weeks that start on Monday) you need to do more arithmetic operations.</p><p>The third approach is the window function. It accepts any fixed duration, and it can also create overlapping (sliding) windows. But it returns a struct (window.start and window.end) instead of a timestamp (although it&#39;s a huge issue, IMHO), and it does not support months or years. For simple bucketing, a function that returns a timestamp is easier to read and to join on.</p><h3>The time_bucket function</h3><p>The time_bucket function receives a bucket size, a timestamp, and an optional origin, and returns the start of the bucket that contains the timestamp:</p><pre>time_bucket(bucketSize, ts [, origin])</pre><ul><li>bucketSize is a constant interval: day-time intervals (INTERVAL 15 MINUTES, INTERVAL 7 DAYS) or year-month intervals (INTERVAL 1 MONTH, INTERVAL 3 MONTHS).</li><li>ts is a value or column of TIMESTAMP or TIMESTAMP_NTZ, and the produced result will have the same type.</li><li>origin is a constant timestamp that defines where the bucket grid starts. By default, it starts at 1970-01-01 00:00:00 (Unix epoch).</li></ul><p>Examples:</p><pre>SELECT<br>  time_bucket(INTERVAL 15 MINUTES, TIMESTAMP &#39;2024-01-01 11:27:00&#39;),<br>  -- 2024-01-01 11:15:00<br>  time_bucket(INTERVAL 1 HOUR, TIMESTAMP &#39;2024-01-01 11:27:00&#39;,<br>              TIMESTAMP &#39;1970-01-01 00:30:00&#39;),<br>  -- 2024-01-01 10:30:00  (hours that start at :30)<br>  time_bucket(INTERVAL 3 MONTHS, TIMESTAMP &#39;2026-10-18 15:30:00&#39;,<br>              TIMESTAMP &#39;1970-02-01 00:00:00&#39;);<br>  -- 2026-08-01 00:00:00  (fiscal quarters that start in February)</pre><p>The next figure shows the difference between date_trunc and time_bucket: on the same 54 events, date_trunc can only give you buckets equal to hours, while time_bucket gives you buckets of any size you need. (Inspired by <a href="https://lnkd.in/p/dkBVTwQW">Kent Marten&#39;s LinkedIn post</a>).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8pOPYa26WAcvCylnRImmqA.png" /></figure><h3>Example: threshold detections per window</h3><p>Many detection rules have the form “N or more events from one entity within M minutes”. With time_bucket, the window is clearly visible in the query:</p><pre>FROM authentication<br>|&gt; WHERE time &gt;= current_timestamp() - INTERVAL 1 HOUR<br>     AND status_id = 2                -- Failure<br>     AND src_endpoint.ip IS NOT NULL<br>|&gt; AGGREGATE COUNT(*) AS failures, COUNT(DISTINCT user.name) AS users<br>   GROUP BY src_endpoint.ip AS src_ip,<br>            time_bucket(INTERVAL 5 MINUTES, time) AS bucket_start<br>|&gt; WHERE failures &gt;= 20</pre><p>Compare it with the same GROUP BY written with from_unixtime(... % 300): the logic is the same, but here the reader sees &quot;5 minutes&quot; directly, and bucket_start is a real timestamp that you can use in the alert output.</p><p>Fixed buckets have one known weakness: a burst that crosses a bucket edge is split into two or more buckets, and all parts can stay below the threshold. The origin argument gives a cheap fix: we count the same events again on a second grid that is moved by half a bucket. To show this, we generate a burst of 20 failed logins between 10:03:00 and 10:06:48:</p><pre>WITH burst AS (<br>  SELECT &#39;203.0.113.7&#39; AS src_ip, ts<br>  FROM (SELECT explode(sequence(TIMESTAMP &#39;2026-10-16 10:03:00&#39;,<br>                                TIMESTAMP &#39;2026-10-16 10:06:48&#39;,<br>                                INTERVAL 12 SECONDS)) AS ts)<br>)<br>SELECT &#39;grid :00&#39; AS grid, time_bucket(INTERVAL 5 MINUTES, ts) AS bucket_start,<br>       COUNT(*) AS failures<br>FROM burst GROUP BY ALL<br>UNION ALL<br>SELECT &#39;grid :02:30&#39;,<br>       time_bucket(INTERVAL 5 MINUTES, ts, TIMESTAMP &#39;1970-01-01 00:02:30&#39;),<br>       COUNT(*)<br>FROM burst GROUP BY ALL</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/303/1*YQOpwyY4HJw538Qz5_y3kw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*N-x__saHnd4RqNVWNEQFDQ.png" /></figure><p>In a rule, you combine both grids with UNION ALL and keep one row per source IP (i.e., with QUALIFY ROW_NUMBER() OVER (PARTITION BY src_ip ORDER BY failures DESC) = 1). With 2 grids, a burst that lasts at most half of the bucket size (2.5 minutes here) is always inside one bucket of one of the grids. Longer bursts can still be split. If you need an exact sliding window for every event, use a window function with RANGE BETWEEN INTERVAL 5 MINUTES PRECEDING AND CURRENT ROW. It is more precise but also more expensive for large tables.</p><h3>Example: beaconing and persistence</h3><p>A common beaconing hunt computes the gap between consecutive connections for each source and destination pair, and looks for a small standard deviation of the gap (a regular, machine-like cadence). On our lab data (2.09 million network_activity rows over 7 days), the classic query with gap_stddev &lt; 30 seconds finds two pairs:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/481/1*s1h2rhyZfhT-EoFOS9P3zg.png" /></figure><p>The data also contains a third implant that calls home about once per hour with random jitter. Its gaps are between 47 and 71 minutes, so the standard deviation is about 7 minutes, and the query does not find it. Existing C2 frameworks often add jitter to beacons to avoid this type of detection.</p><p>A bucket-based view tolerates jitter better. Instead of the exact gaps, we count the hourly buckets in which the pair was active, and divide this number by the number of hours between the first and the last connection:</p><pre>FROM network_activity<br>|&gt; WHERE time &gt;= current_timestamp() - INTERVAL 7 DAYS<br>     AND src_endpoint.ip IS NOT NULL AND dst_endpoint.ip IS NOT NULL<br>|&gt; EXTEND time_bucket(INTERVAL 1 HOUR, time) AS hour_bucket<br>|&gt; AGGREGATE<br>     COUNT(DISTINCT hour_bucket) AS active_hours,<br>     COUNT(*) AS connections,<br>     MIN(time) AS first_seen,<br>     MAX(time) AS last_seen<br>   GROUP BY src_endpoint.ip AS src_ip, dst_endpoint.ip AS dst_ip<br>|&gt; EXTEND TIMESTAMPDIFF(HOUR, time_bucket(INTERVAL 1 HOUR, first_seen), last_seen) + 1<br>          AS span_hours<br>|&gt; EXTEND ROUND(active_hours / span_hours, 2) AS active_ratio,<br>          ROUND(connections / active_hours, 1) AS calls_per_active_hour<br>|&gt; WHERE span_hours &gt;= 24 AND active_ratio &gt;= 0.7<br>|&gt; ORDER BY active_ratio DESC, span_hours DESC</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/643/1*gs2pAGup7b2CSZhUYBKDyw.png" /></figure><p>Now all three implants are in the result. Read the two last columns together: active_ratio shows persistence, and calls_per_active_hour shows the cadence. The 1-minute and 5-minute beacons fill every hour, so they have a ratio of 1.0 and 60 or 12 calls per hour. The hourly beacon has a lower ratio (0.88) because the jitter sometimes moves 2 calls into one hour and leaves the next hour empty. It has about 1 call per active hour. For this reason, the filter is &gt;= 0.7 and not &gt;= 0.9. If you want the ratio of a jittered hourly beacon to be closer to 1.0, use 2-hour buckets.</p><p>The first version of this hunt was sorted only by active_ratio, and the hourly beacon was ranked last, after the two fast beacons. The cadence column fixed the ranking. Always-on services (update servers, EDR and telemetry back-ends) also have a high ratio, so you still need an allow-list, or a check of how many hosts talk to the same destination.</p><h3>Migrating queries from other SIEMs</h3><p>Each major SIEM query language has a time-bucketing operator, and most of them accept any bucket size. Before time_bucket, the translation to Databricks SQL was either date_trunc (for whole units only) or Unix-seconds arithmetic. Now we have a direct mapping:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/767/1*s-Pt-9qQgbTZDmWiZQgC-Q.png" /></figure><p>Here is a typical Sentinel analytics rule</p><pre>SigninLogs<br>| where TimeGenerated &gt; ago(1h)<br>| where ResultType != 0<br>| summarize failures = count() by bin(TimeGenerated, 15m), UserPrincipalName<br>| where failures &gt; 10</pre><p>and its translation to the OCSF authentication table:</p><pre>FROM authentication<br>|&gt; WHERE time &gt; current_timestamp() - INTERVAL 1 HOUR<br>     AND metadata.product.name = &#39;SignInLogs&#39;<br>     AND status_id = 2<br>|&gt; AGGREGATE COUNT(*) AS failures<br>   GROUP BY time_bucket(INTERVAL 15 MINUTES, time) AS bucket_start,<br>            user.name AS user_principal_name<br>|&gt; WHERE failures &gt; 10</pre><p>There are a few details where a direct mapping can give different numbers:</p><ul><li>Weeks. KQL counts time from 0001-01-01, which was a Monday, so bin(TimeGenerated, 7d) gives weeks that start on Monday. The default origin of time_bucket is 1970-01-01, which was a Thursday. For weeks that start on Monday, pass a Monday as the origin: time_bucket(INTERVAL 7 DAYS, time, TIMESTAMP &#39;1970-01-05&#39;) (or use date_trunc(&#39;WEEK&#39;, time)). For 2026-10-18, the default origin returns Thursday 2026-10-15, and the Monday origin returns Monday 2026-10-12, which matches the KQL results.</li><li>Time zones. Splunk and Sumo Logic create day buckets in the user’s time zone. With a TIMESTAMP argument, time_bucket creates day, month, and year buckets in the session time zone, which is usually UTC. If the analysts of the original SIEM worked in another time zone, the daily counts would shift by some hours. Set the session time zone, or convert the timestamp before you bucket it.</li><li>Alignment options. Splunk bin ... aligntime=@h+30m and KQL bin_at map to the origin argument.</li></ul><p>I added these mappings to the Lakewatch migration skills (<a href="https://alexott.blogspot.com/2026/09/how-custom-skill-helps-with-agentic.html">covered in a separate post</a>) and compared the resulting translations with and without the new guidance. Without it, the translations used from_unixtime string buckets and epoch weeks that start on Thursday. The Splunk translation generated calls to date_bin and date_trunc(&#39;15 minutes&#39;), resulting in &quot;date_bin does not exist in Databricks SQL&quot; and date_trunc(&#39;15 minutes&#39;) returning NULL. With the guidance, the translations used time_bucket with the correct origin.</p><h3>Limitations</h3><p>There are some things to know before you use the function:</p><ul><li>It requires Databricks Runtime 19 or later (or a current channel for Databricks SQL warehouse). On older Databricks runtimes, use timestamp_seconds(floor(unix_seconds(time) / N) * N).</li><li>bucketSize and origin must be constants. If you pass a column as origin, the query will fail with NON_FOLDABLE_INPUT, so you cannot use a separate grid for each entity (for example, buckets that start at the first event of each incident).</li><li>Buckets are aligned to 1970-01-01 by default, which matters for weeks and for any size that does not divide evenly into a day.</li><li>The function only maps a timestamp to a bucket. It does not create empty buckets, so for gap filling (i.e., KQL make-series, Splunk timechart with zeros) you still generate the buckets with sequence() and join the data to them.</li><li>Fixed buckets have edges. For threshold rules, use 2 grids or a sliding window, as shown above. The window function with a slide duration (for example, window(time, &#39;10 minutes&#39;, &#39;5 minutes&#39;)) is another way to get overlapping buckets.</li></ul><h3>Wrap up</h3><p>Everything that time_bucket does was possible before with date_trunc or arithmetic on Unix seconds. This function replaces the error-prone arithmetic with a readable function call. It accepts any interval size, returns a real timestamp, and its origin argument handles shifted grids, Monday-starting weeks, and fiscal quarters. For security teams, it helps in threshold detections, beaconing, and persistence hunts, etc. It helps a lot with migrating queries from KQL, SPL, etc.: bucketing operators are very common there, and now they have a direct equivalent in Databricks SQL.</p><h3>References</h3><ul><li><a href="https://docs.databricks.com/aws/en/sql/language-manual/functions/time_bucket">time_bucket function</a> (Databricks documentation).</li><li><a href="https://learn.microsoft.com/en-us/kusto/query/bin-function">bin() function</a> and <a href="https://learn.microsoft.com/en-us/kusto/query/bin-at-function">bin_at() function</a> (Kusto Query Language documentation).</li><li>Blog series: New Databricks SQL functionality applied for cybersecurity:</li><li>Detecting attack sequences in Databricks SQL with MATCH_RECOGNIZE, <a href="https://alexott.blogspot.com/2026/09/detecting-attack-sequences-in.html">Part 1</a>, <a href="https://alexott.blogspot.com/2026/09/detecting-attack-sequences-in_0330322033.html">Part 2</a>.</li><li><a href="https://alexott.blogspot.com/2026/09/native-ip-functions-for-threat.html">Native IP functions for threat detection and hunting on Databricks</a>.</li><li><a href="https://alexott.blogspot.com/2026/08/why-databricks-sql-pipe-syntax-is-great.html">Why Databricks SQL pipe syntax is great for cybersecurity work</a>.</li><li><a href="https://alexott.blogspot.com/2026/08/parsing-timestamps-without-pain-with.html">Parsing timestamps without the pain with new Databricks SQL functions</a>.</li></ul><p><a href="https://alexott.blogspot.com/2026/10/databricks-sql-flexible-time-windows.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=08e11d28997e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying Databricks Genie Agents to multiple environments with DABs]]></title>
            <link>https://medium.com/@alexott_en/deploying-databricks-genie-agents-to-multiple-environments-with-dabs-9f32b4724032?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/9f32b4724032</guid>
            <category><![CDATA[databricks-asset-bundles]]></category>
            <category><![CDATA[databricks-genie]]></category>
            <category><![CDATA[databricks]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Sun, 18 Oct 2026 10:51:34 GMT</pubDate>
            <atom:updated>2026-10-18T10:52:09.397Z</atom:updated>
            <content:encoded><![CDATA[<p>Genie Agents (formerly Genie spaces) are now supported in Declarative Automation Bundles (DABs) as a separate resource type (still under the old name). You can keep the agent definition in Git, review it, and deploy it with databricks bundle deploy together with other resources (jobs, pipelines, etc.) that produce data for it. But there is one problem that you hit almost immediately: the agent definition contains hardcoded table names (same for other data sources). This post explains why this happens, and how it could be fixed with a small piece of Python code for DABs.</p><h3>Where are table names used in the Genie Agent?</h3><p>Usually, people create an initial version of the agent in the Databricks UI (although you can also use an agent + Databricks skills), and if you want to deploy it with DABs, then you can extract the agent definition with databricks bundle generate genie-space. This command does two things:</p><ul><li>writes a YAML file with DABs resource declaration into the resources directory;</li><li>writes a file with a .geniespace.json extension into the src directory; it contains a serialized agent definition.</li></ul><p>The resource declaration references that JSON file via file_path. I.e., resources/lakewatch_security_operations.genie_space.yml contains the following:</p><pre>resources:<br>  genie_spaces:<br>    security_operations:<br>      title: &quot;Lakewatch Security Operations&quot;<br>      warehouse_id: ${var.warehouse_id}<br>      file_path: ../src/lakewatch_security_operations.geniespace.json<br>      ...</pre><p>The serialized agent uses fully-qualified Unity Catalog names (catalog.schema.table). And references to tables aren&#39;t only in the list of tables. For example, our “Lakewatch Security Operations” agent works with 22 OCSF gold tables, and its definition has 72 references to those tables:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/451/1*ElfFPm3JpC43mN1OFXESYg.png" /></figure><p>Some of these references are table names in SQL (FROM fe_lakewatch_catalog.gold.authentication), and some of them are column references that use fully qualified table names (fe_lakewatch_catalog.gold.authentication.status_id = 2). Table names can also be used in text instructions and table descriptions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eMIm3FJa1xxeU6en2sB4hA.png" /></figure><p>In a real project, the development, staging, and production environments usually use different catalog, schema, or table names. When you deploy an example from above to a dev target that should read from dev_catalog.gold, and you don&#39;t replace references, the agent will still try to query the referenced tables and fail if the tables aren&#39;t visible in the given environment.</p><h3>Why the built-in functionality isn’t applicable</h3><p>One of the initial recommendations I received was to put the agent definition directly in the resource file. The serialized_space field accepts the serialized JSON as a YAML string, and in this case DABs interpolates variables in it, so ${var.catalog}.gold.authentication works (it was checked with CLI v1.19.0). So it works, but it&#39;s difficult to maintain:</p><ul><li>the definition of a tuned agent could be large (i.e., my example genie agents have sizes from 15 KB to 136 KB);</li><li>a diff of a long JSON string in YAML is difficult to review;</li><li>bundle generate genie-space always writes a separate JSON file. After each sync from the UI you must convert the file again and put the variables back.</li></ul><p>The second try was to put bundle variables into the JSON file, for example ${var.catalog}.gold.authentication. This does not work: DABs reads the file into the serialized_space field but does not interpolate variables in it (at least not in CLI v1.19), so the agent receives ${var}.catalog} text as is. Even if this worked, the JSON file stops being a valid agent definition, and the round trip with the databricks bundle generate genie-space --resource &lt;key&gt; --force (edit in the UI, then sync back to Git) overwrites these placeholders.</p><p>The third approach was to perform a text replacement in a deployment script, i.e., using sed. This works for simple cases, but there were a few things that make it fail later:</p><ul><li>A rename of a schema and a rename of a single table in it must not interfere. If you run two replacements one after another, the output of the first can match the second.</li><li>A replacement must check the name’s boundaries on both sides. For a table rename, people often replace main.intel.oss_iocs. with a dot at the end, so that the replacement does not change another table such as main.intel.oss_iocs_archive. But this pattern does not find FROM main.intel.oss_iocs WHERE ..., where a space follows the name. Without the dot, the replacement also changes the other table. (Of course, we can use word-boundary anchors in regexes, but you need to remember about them).</li><li>Unity Catalog names are case-insensitive by default, and Genie sometimes writes them with backticks (`main`.`gold`.`authentication`), i.e., when they contain special characters.</li><li>The Genie API requires that the list of tables be sorted by identifier. If a rewrite changes the order of table names, the API rejects the request with the error data_sources.tables must be sorted by identifier.</li><li>Join specs use the short table name as a qualifier (authentication.actor.user.uid = account_change.actor.user.uid), and SQL snippets (filters and measures) can do the same. In our IoC agent, the filter “URL IoCs only” is oss_iocs.ioc_type = &#39;URL&#39;. If you rename a table, you must also change these qualifiers. Genie does not check the SQL of snippets when you deploy the agent, so a filter with the old name is accepted, and it will fail only when Genie uses it to generate a SQL query.</li></ul><p>I already had a <a href="https://github.com/databrickslabs/sandbox/tree/main/supervisor-agent-export-import#single-genie-space-tools">script for importing of genie agents</a>, but it didn’t integrate well with DABs. I also had an internal project that used PyDABs, but it was hard to reuse across multiple projects — you need to copy files manually, making bug fixes causing a headache, etc. So I decided to create a reusable implementation.</p><h3>Python mutators in DABs</h3><p>DABs supports the use of Python code (via the databricks-bundles package, often referred to as PyDABs) for two tasks: to define resources in Python, and to change resources that are already defined in YAML. The second task is based on the concept of <em>mutators</em>. A mutator is a function that receives the bundle configuration (with all resolved variables for the current target) and a resource of a given type, and returns a modified copy of that resource. The CLI calls the mutator for every resource of a given type during validate and deploy.</p><p>For Genie agents, the CLI first reads the file_path file into the serialized_space field, and then calls the mutators. This means that a mutator sees the full agent definition and can change it for each target.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CPJWBov-E7j5xeEsv4yF1g.png" /></figure><h3>Implementing the genie_agent_retarget mutator</h3><p>This mutator is implemented in the <a href="https://github.com/alexott/pydabs-genie-agent-retarget">pydabs-genie-agent-retarget</a> repository. It has a single Python module, genie_agent_retarget. It reads rewrite rules from the bundle variable named genie_table_mapping. Each rule maps a source prefix to a target prefix. A single rule can define one of 3 levels:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/436/1*pvUXsuT6Xz9pr7XNlfUNPw.png" /></figure><p>The source and the target of a rule must have the same number of parts. If they do not, databricks bundle validate fails and shows the failing rule:</p><pre>ValueError: rule &#39;main.gold&#39; -&gt; &#39;dev&#39;: source and target must have the same number of parts</pre><p>We’re starting with the most specific rules: a table rule is used before a schema rule, and a schema rule is used before a catalog rule. A part of a rule can be in backticks — the same as in SQL. In YAML, a value cannot start with a backtick, so put such a rule in quotes: &quot;`my-catalog`.gold&quot;: &quot;`other-catalog`.gold&quot;.</p><p>The mutator works as follows:</p><ol><li>It parses the serialized agent as JSON and checks every string value in it. Table names can be used in various places (table identifiers, join specs, SQL snippets, example and benchmark SQLs, instructions, descriptions), and additional fields could be added in future versions of the agent serialization format. This mutator doesn’t use a predefined list of fields, so the current implementation will also work for new fields that could be introduced in the future.</li><li>In each string, it finds names with 3 parts, where each part is a plain identifier or a backtick-quoted identifier. A longer chain such as catalog.schema.table.column is also a match (the first 3 parts are the table). A name that continues a longer chain on the left (for example, a.catalog.schema.table) doesn&#39;t match, and names with 1 or 2 parts (struct fields such as actor.user) are never changed.</li><li>For each name, it finds the most specific rule and rewrites that name once. The rewrite is done in one pass, so rules never chain.</li><li>If a table rule renamed a table, it also renames the short qualifiers of that table in the SQL fields: join specs, SQL snippets, example SQL, and benchmark SQL. It does not do this in free text, and it does not do it when another table within the agent has the same short name, because the qualifier is then ambiguous.</li><li>At the end, it sorts data_sources.tables by identifier, and returns the agent with an inline serialized_space (we also need to reset the file_path to None, because these two fields are mutually exclusive, so DABs will generate an error if both are specified).</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o6foxCrG-uOKROC_LdXhdA.png" /></figure><p>If the rules do not change anything, the mutator returns the resource unchanged. So a target that uses the same table names as specified in the serialized content will get the original file.</p><h3>An example bundle</h3><p>The repository has an example bundle in the example/ directory. It deploys 3 agents from my previous <a href="https://alexott.blogspot.com/2026/07/building-agentic-cybersecurity.html">threat analyst blog post</a>: “Analysis of CVEs”, “Analysis of Indicators of Compromise”, and “Lakewatch Security Operations”. The definitions were created against fe_lakewatch_catalog.lakewatch_field (CVE and IoC tables) and fe_lakewatch_catalog.gold (OCSF tables). The bundle has 3 targets:</p><ul><li>dev is in the same workspace. It moves the agents to other schemas, and it renames the IoC table from oss_iocs to iocs.</li><li>azure is in another workspace, where the same data is in other catalogs: the OCSF tables are in lw_aottdb_ws.gold, the CVE table is in cybersecurity.datasets, and the IoC table is cybersecurity.threatintel.iocs.</li><li>prod uses the names from the files without changes.</li></ul><p>The profiles, catalogs, and schemas in the example are from my environments. To try it in your workspace, change them in a target to point to your tables (the <a href="https://github.com/alexott/pydabs-genie-agent-retarget#example">README</a> explains what to change).</p><p>The project has the following layout:</p><pre>example/<br>├── databricks.yml<br>├── pyproject.toml<br>├── resources/<br>│   ├── analysis_of_cves.genie_space.yml<br>│   ├── analysis_of_indicators_of_compromise.genie_space.yml<br>│   └── lakewatch_security_operations.genie_space.yml<br>└── src/<br>    ├── analysis_of_cves.geniespace.json<br>    ├── analysis_of_indicators_of_compromise.geniespace.json<br>    └── lakewatch_security_operations.geniespace.json</pre><p>The files have the same format as the output of the databricks bundle generate genie-space command. The original JSON files weren&#39;t changed. In the YAML files, we only need to replace two values that bundle generate writes as constants: warehouse_id (now ${var.warehouse_id}) and parent_path. All other configuration is in databricks.yml:</p><pre>bundle:<br>  name: genie-agent-retarget-example<br>  engine: direct<br><br>python:<br>  venv_path: .venv<br>  mutators:<br>    - &#39;genie_agent_retarget:retarget_genie_spaces&#39;<br>include:<br>  - resources/*.yml<br>variables:<br>  ocsf_schema:<br>    default: fe_lakewatch_catalog.gold<br>  cve_schema:<br>    default: fe_lakewatch_catalog.lakewatch_field<br>  iocs_table:<br>    default: fe_lakewatch_catalog.lakewatch_field.oss_iocs<br>  warehouse_name:<br>    default: &quot;Lakewatch Warehouse&quot;<br>  warehouse_id:<br>    lookup:<br>      warehouse: ${var.warehouse_name}<br>  genie_table_mapping:<br>    type: complex<br>    default:<br>      fe_lakewatch_catalog.gold: ${var.ocsf_schema}<br>      fe_lakewatch_catalog.lakewatch_field: ${var.cve_schema}<br>      fe_lakewatch_catalog.lakewatch_field.oss_iocs: ${var.iocs_table}<br>targets:<br>  dev:<br>    default: true<br>    mode: development<br>    workspace:<br>      profile: lw-field<br>    variables:<br>      ocsf_schema: fe_lakewatch_catalog.genie_demo_ocsf<br>      cve_schema: fe_lakewatch_catalog.genie_demo_intel<br>      iocs_table: fe_lakewatch_catalog.genie_demo_intel.iocs<br>  azure:<br>    mode: development<br>    workspace:<br>      profile: az-alexott<br>    variables:<br>      ocsf_schema: lw_aottdb_ws.gold<br>      cve_schema: cybersecurity.datasets<br>      iocs_table: cybersecurity.threatintel.iocs<br>  prod:<br>    mode: production<br>    workspace:<br>      profile: lw-field</pre><p>There are two things to note about this configuration:</p><ul><li>The keys of genie_table_mapping are the table names used in the JSON files, so they don&#39;t change between targets. The values use normal bundle variables, and DABs resolves them before it calls the mutator. We use one variable for the location of each data source (a schema or a table), so a target only sets these variables, just as for jobs and pipelines.</li><li>A complex variable is replaced as a whole when a target overrides it. If one target needs a different set of rules, it is better to override the simple variables that the rules use.</li></ul><p>The mutator must be available in the bundle’s virtual environment. With uv, just add it to pyproject.toml (you can also copy genie_agent_retarget.py next to databricks.yml, because it has no other dependencies):</p><pre>[dependency-groups]<br>dev = [<br>    &quot;databricks-bundles==1.19.0&quot;,<br>    &quot;pydabs-genie-agent-retarget&quot;,<br>]</pre><h3>The test results</h3><p>databricks bundle validate shows the result of applying the mutator, so you can check the rewritten names before you deploy:</p><pre>databricks bundle validate -o json | jq -r \<br>    &#39;.resources.genie_spaces[].serialized_space | fromjson<br>     | .data_sources.tables[0].identifier&#39;<br>fe_lakewatch_catalog.genie_demo_intel.cves_v5<br>fe_lakewatch_catalog.genie_demo_intel.iocs<br>fe_lakewatch_catalog.genie_demo_ocsf.account_change</pre><p>After performing the databricks bundle deploy, we read the agent back via the Genie API. All 94 references across the 3 agents (11 + 11 + 72) pointed to the new schemas, and there were no references left to the original schemas. We also validated that Genie Agent worked by asking each agent a question. I.e., the IoC agent wrote this SQL for “How many IoCs are in the dataset, broken down by type?”:</p><pre>SELECT `ioc_type`, COUNT(*) AS `ioc_count`<br>FROM `fe_lakewatch_catalog`.`genie_demo_intel`.`iocs`<br>WHERE `ioc_type` IS NOT NULL<br>GROUP BY `ioc_type`<br>ORDER BY `ioc_count` DESC</pre><p>The implementation was also tested in other workspaces with different combinations of catalogs and schemas.</p><h3>Limitations</h3><p>There are some limitations that you need to know about:</p><ul><li>The rules work only with 3-part names. If the SQL in an agent uses unqualified table names (FROM authentication), the mutator does not change them, and the query uses the session&#39;s default catalog and schema. Genie Agent by default writes fully-qualified names into serialized content, so this is mainly a problem for SQL that was written by hand.</li><li>A catalog-level rule rewrites any 3-part name that starts with this catalog, in any field. If your catalog has the same name as a struct column (for example, a catalog actor), a struct path such as actor.user.name is also rewritten. In this case, use schema-level rules to avoid this problem.</li><li>The Genie API verifies that all the agent’s tables exist when it creates or updates the agent. If a table does not exist, the deployment fails with an error such as Table &#39;main.gold.authentication&#39; does not exist. So in a new environment, the tables must exist before you deploy the agent. If the same bundle includes the jobs that create these tables, keep the agents in a separate bundle and deploy it after the jobs run for the first time.</li><li>The mutator only changes table references. Other values in the definition, such as the warehouse ID, are specified as fields in the resource declaration, and you set them with bundle variables (as with warehouse_id above).</li><li>Right now we retarget only tables/schemas/catalogs, and there is no support for other data sources (i.e., UC Volumes) yet.</li><li>bundle generate genie-space --watch downloads the deployed definition. If you edit the agent in a target that uses other table names, the downloaded file contains those names. Either edit the agent in the target whose names match the original JSON files, or convert the file back with the command-line version of the same code:</li></ul><pre>python -m genie_agent_retarget src/x.geniespace.json --in-place \<br>  -m fe_lakewatch_catalog.genie_demo_ocsf=fe_lakewatch_catalog.gold</pre><h3>Conclusion</h3><p>The serialized Genie agent allows us to store an agent in Git, but it binds the agent to the catalog and schemas where you created it. A Python mutator for DABs can rewrite these names for each target during deployment. With genie_agent_retarget, you register the mutator once, describe the differences between environments using table/schema/catalog rules, and keep the files from bundle generate unchanged. The code and the example bundle are in the <a href="https://github.com/alexott/pydabs-genie-agent-retarget">pydabs-genie-agent-retarget</a> repository. This package is also <a href="https://pypi.org/project/pydabs-genie-agent-retarget/">published to PyPI</a>, so you can just refer to it in your Python project.</p><p><a href="https://alexott.blogspot.com/2026/10/deploying-databricks-genie-agents-to.html">Crosspost from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9f32b4724032" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Inside the Databricks Terraform exporter: more implementation details]]></title>
            <link>https://medium.com/@alexott_en/inside-the-databricks-terraform-exporter-more-implementation-details-ce620b43aa53?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/ce620b43aa53</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[terraform]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Thu, 08 Oct 2026 10:45:09 GMT</pubDate>
            <atom:updated>2026-10-08T10:45:09.762Z</atom:updated>
            <content:encoded><![CDATA[<p>In the previous post, <a href="https://alexott.blogspot.com/2026/10/introduction-to-databricks-terraform.html">Introduction to the Databricks Terraform exporter</a>, I described what the Databricks Terraform exporter does and how to use it. This post looks into implementation details: how the exporter describes a resource type, how you can add support for a new one, and other details. This post doesn’t touch the topic of referential integrity — we have a <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">separate blog post for it</a>.</p><h3>How the code is organized</h3><p>The exporter is a single Go package in the exporter/ directory of the <a href="https://github.com/databricks/terraform-provider-databricks">Databricks Terraform provider</a>. The files are split per specific part of work: command-line handling, the resource model, the per-service logic, and code generation. Such a split makes it easier to find the right place for a change, so this section provides a map for anyone who wants to contribute to the exporter.</p><p>The entry point and the shared state are in the following files:</p><ul><li>command.go holds the exported Run() function that is called by the provider. It parses the command-line flags, sets up the client, and starts the export.</li><li>context.go defines the importContext structure, which is the central state object for an export run. Almost every function takes it as the ic argument or struct instance. It implements the two-pass model (described in the <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">references post</a>): the worker goroutines that list and import objects, the Emit and Add methods that queue resources for processing, and the code that reads an object&#39;s state through either framework.</li><li>ui.go has the small helpers for the interactive prompts (askFor, askFlag) used when you run the exporter interactively.</li></ul><p>The resource model and the registry of supported types are separate:</p><ul><li>model.go defines the core data types: the importable structure described below, the resource type that represents a single discovered object, the reference type for dependencies, and the import-command generation.</li><li>importables.go is the registry. It holds one importable entry per supported Databricks resource type in a large map. This is the first file you need to change when you add a resource.</li><li>impl_&lt;service&gt;.go holds the per-service functions that the importable entries point at: the functions List, Import, Name, and other required functions for a service, such as impl_uc.go for Unity Catalog, impl_compute.go for compute, or impl_jobs.go for jobs. Keeping these out of importables.go keeps the registry readable: each entry stays close to a declaration, and the implementation lives next to its service.</li></ul><p>Code generation is implemented in two files:</p><ul><li>codegen.go is the HCL generation pass, including unifiedDataToHcl(), the field rules, the variable generation, and the code that writes the .tf files.</li><li>abstractions.go holds the framework-abstraction layer: the ResourceDataWrapper and SchemaWrapper interfaces and the FieldType enumeration. This is what hides whether a resource is SDKv2 or Plugin Framework, so the generation pass can treat both the same way. The “Two frameworks, one generator” section below describes how this is used.</li></ul><p>The rest of the files are supporting functions:</p><ul><li>util.go, util_compute.go, util_scim.go, and util_workspace.go hold shared helpers, such as the service-enablement checks, name matching, and the effective fields helper.</li><li>cloud_utils.go and node_type_mapping.go implement the -targetCloud conversions, which rewrite cloud-specific attributes and node types for a different cloud.</li><li>logging.go sets up the log-level filtering and formatting.</li></ul><p>Tests follow a fixed naming pattern: each *.go file has a matching *_test.go file, the per-service tests are in impl_&lt;service&gt;_test.go, and exporter_test.go holds the larger integration-level tests. A few patterns also have their own Markdown notes in the directory, for example, EFFECTIVE_FIELDS_PATTERN.md and COMPLEX_TYPES_HANDLING.md, which are worth reading before you work on the pattern they describe.</p><h3>One structure per resource type</h3><p>The exporter keeps the logic for importing a resource type separate from the logic that generates the HCL (HashiCorp Configuration Language) code. Each resource type is described by an importable structure that is tied to a specific Databricks Terraform resource. For most resource types, you set only a few fields: the Service, the List function (also optional), a Name function, an Import function, and whether the resource is WorkspaceLevel or AccountLevel, and the Depends references.</p><p>The full set of fields:</p><ul><li>Service is the logical group the resource belongs to (compute, notebooks, access, and so on). These are the values you pass to the -services and -listing command-line options.</li><li>List (optional) lists the objects of the resource type. It emits each object&#39;s ID; the actual read happens later.</li><li>Search (optional) finds the ID of an object referred to by a non-ID attribute, for example a user referenced by display name (users/groups/service principals are identified by SCIM ID).</li><li>Name generates the Terraform resource name for one instance (the SDKv2 signature). NameUnified does the same for both frameworks through the ResourceDataWrapper abstraction described below.</li><li>Import runs after the object&#39;s state is read, and emits additional objects: references to permissions, writes files to disk for init scripts, normalizes data, and so on.</li><li>Depends declares references to other resources, which is what keeps the generated code referentially consistent. A separate post, <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">Inside the Databricks Terraform exporter: referential integrity</a>, describes this field in detail.</li><li>Body customizes the generated HCL block. It is rarely needed, only for a few complex cases such as databricks_mount, where nested blocks depend on the storage URL.</li><li>Ignore (optional) decides whether an object should be skipped.</li><li>ShouldOmitField (optional) decides whether a field should be left out of the generated code, usually to drop computed attributes or default values. ShouldGenerateField (optional) forces a field to be generated regardless of its value (sometimes we need to generate zero values even for optional fields). Both have unified variants (ShouldOmitFieldUnified, ShouldGenerateFieldUnified) that work with both frameworks.</li><li>ApiVersion (optional) sets the REST API version for the resource, for example, version 2.1 for databricks_job.</li><li>AccountLevel and WorkspaceLevel mark where the resource can be exported.</li><li>PluginFramework marks a resource that is implemented with the Terraform Plugin Framework rather than SDKv2.</li></ul><p>This declarative model is what makes the exporter straightforward to extend: a new resource is mostly a new importable entry, not new code in the generator.</p><h3>Adding a new resource</h3><p>Adding support for a resource type follows a fixed set of steps:</p><ol><li>Define a new importable in importables.go, and mark it as workspace-level, account-level, or both.</li><li>Assign it to a service. Reuse an existing one if it fits, or add a new service. Put the per-service functions in impl_&lt;service&gt;.go and the tests in impl_&lt;service&gt;_test.go.</li><li>Implement List to discover and emit instances (prefer the Go SDK&#39;s List method over ListAll).</li><li>Implement Import to emit the resource&#39;s dependencies, such as permissions or grants.</li><li>Add Depends to describe the references between this resource&#39;s fields and the resources they point at.</li><li>Add ShouldOmitField if some fields need to be omitted conditionally.</li><li>Add a unit test that checks the generated code. For resources that use the Go SDK, use qa.MockWorkspaceApply or qa.MockAccountsApply.</li><li>Update the support matrix and the service list in the documentation.</li></ol><p>Emission between resources can go in both directions. A newly added resource can emit the existing resources it references: for example, databricks_data_quality_monitor emits the databricks_sql_table it monitors, and that table&#39;s own import then emits the table&#39;s schema and catalog. An existing resource can also be updated to emit a newly added one once the exporter supports it. I.e., databricks_job could emit a new resource that wasn&#39;t supported before.</p><p>References can also be ambiguous, for example, a table or schema name that exists in several catalogs — in these cases, you need to implement an IsValidApproximation function on the reference (again, see the <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">references post</a>).</p><h3>From resource data to HCL</h3><p>Code generation runs as a separate pass, after listing and import have finished for every resource (the two-pass model described in the <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">references post</a>). In the code generation pass, we iterate over the imported resources and, for each one, first check the resource’s Ignore function: a resource that Ignore rejects is skipped, and nothing is generated for it. For a resource that is kept, the exporter creates its block (a resource block, or a data block for a data source) and fills it in.</p><p>Filling in a block is the job of unifiedDataToHcl(), which is executed once per resource and iterates over the fields recursively. It&#39;s a two-step process: first, it decides which fields to write, then it writes each one.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gob4iEqadIzJA62C1Pq3dQ.png" /></figure><p>The first step iterates over the resource schema and applies a fixed set of rules to every field. Fields are visited in reverse alphabetical order; the exporter keeps this order for a consistent and readable layout. For each field, the exporter reads the current value and decides whether to write it:</p><ul><li>Omission comes first. A resource can ShouldOmitField or ShouldOmitFieldUnified to drop a field; without one, the default rule removes computed attributes and values that are equal to the schema default.</li><li>An unset or zero value is skipped, unless the field is required or holds a value that differs from its default. For Plugin Framework resources, an optional primitive that is still at its zero value is also skipped.</li><li>ShouldGenerateField can force a field back in when the rules above would drop it (this is typically a workaround for fields that are incorrectly marked as optional in the OpenAPI spec and propagated to the Databricks Go SDK).</li></ul><p>The second step writes each remaining field as HCL. Most of this logic is shared between the two provider frameworks; the one framework-specific difference is described in the next section. The output is shaped by the field type:</p><ul><li>Strings and integers pass through the reference step (described below) and are written as raw tokens, so a raw identifier can become a Terraform reference.</li><li>Booleans and floating-point numbers are written directly as values.</li><li>A map becomes an HCL object with its keys sorted.</li><li>A list or a set recurses into a nested structure: each element is written one by one, and the same field rules apply against individual values.</li></ul><p>The reference step is a single call, ic.reference(), executed for each string and number value. This is the one place where a raw identifier can be replaced with a Terraform reference to another resource, a path to a generated file, or a variable. How that replacement is resolved is the subject of the <a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">references post</a>.</p><p>After the fields, a top-level resource can also get its depends_on list, built from the explicit dependencies that the import step recorded.</p><h3>Two frameworks, one generator</h3><p>The generation loop described above is shared, but the provider has two kinds of resources in it. The Databricks provider is moving its resources from the older Terraform SDKv2 to the newer Terraform Plugin Framework, and both kinds exist in the provider at the same time. The exporter generates code for both through the single unifiedDataToHcl() function.</p><p>The unifiedDataToHcl() function reads resource data and schema through two interfaces, ResourceDataWrapper and SchemaWrapper, which hide whether the underlying resource is SDKv2 or Plugin Framework. The field walk, omission rules, variable references, and depends_on handling is all shared - only the step that writes a single field is framework-specific.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HuhSyUH2wQDzcn1uWCErPA.png" /></figure><p>There is one difference you can see in the generated code. SDKv2 writes a nested object as a block, and the Plugin Framework writes it as an attribute:</p><pre># SDKv2: nested structure as a block<br>evaluation {<br>  # ...<br>}<br><br># Plugin Framework: nested structure as an attribute<br>evaluation = {<br>  # ...<br>}</pre><p>When you add a Plugin Framework resource, you set PluginFramework: true, use NameUnified instead of Name, and use the unified ShouldOmitFieldUnified and ShouldGenerateFieldUnified callbacks for any custom field logic.</p><h3>Effective fields and complex types</h3><p>Some Plugin Framework resources have a specific problem. For example, the Lakebase database instance API uses an “effective fields” pattern: you configure an input field such as node_count, but the API&#39;s read operation does not return node_count. Instead, it returns effective_node_count. The exporter reads the resource through that read operation, so it sees only the effective_* fields, while the generated configuration needs the input fields. This is typically needed to support default values for some attributes - we can&#39;t return a new value for an attribute that wasn&#39;t provided by the user, otherwise we&#39;ll get an error.</p><p>The exporter handles this by copying each effective_* value back to its input field during import. A resource enables this by calling a helper from its Import function, and by using a ShouldOmitFieldUnified callback that omits an input field when its value is zero or equal to the default, so the generated code stays minimal. The zero check uses reflection rather than the schema&#39;s “is set” check, because a field set to false or 0 is still set, and would otherwise be written out unnecessarily. Required fields are never omitted, even when their value is zero.</p><p>Simple types (strings, numbers, booleans) copy directly. Complex types, such as a list of objects like custom_tags, do not: the API returns native Go SDK types, while the Plugin Framework expects its own type system, and there is no direct conversion between the two. For these, the exporter uses a converter-based helper (copyEffectiveFieldsToInputFieldsWithConverters) that converts the Terraform state to a Go SDK struct, copies all Effective* fields by reflection and converts back through the provider&#39;s existing type converters. This one call handles both simple and complex types, and it is the recommended approach for Plugin Framework resources that use effective fields.</p><h3>References</h3><ul><li><a href="https://alexott.blogspot.com/2026/10/introduction-to-databricks-terraform.html">Introduction to the Databricks Terraform exporter</a> is the overview: what the exporter does and how to run it.</li><li><a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">Inside the Databricks Terraform exporter: referential integrity</a> describes the Depends field and reference resolution in detail.</li><li><a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">Speeding up the Databricks Terraform exporter</a> describes the parallel and incremental export.</li><li><a href="https://github.com/databricks/terraform-provider-databricks/blob/main/exporter/AGENTS.md">Exporter-specific AGENTS.md</a> also provides quite a lot of implementation details, so you can read it as well.</li></ul><p><a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter_0312627556.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ce620b43aa53" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Inside the Databricks Terraform exporter: referential integrity]]></title>
            <link>https://medium.com/@alexott_en/inside-the-databricks-terraform-exporter-referential-integrity-5f3f7cea3428?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/5f3f7cea3428</guid>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[databricks]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 05 Oct 2026 08:00:28 GMT</pubDate>
            <atom:updated>2026-10-05T08:01:05.723Z</atom:updated>
            <content:encoded><![CDATA[<p>In the previous post, <a href="https://alexott.blogspot.com/2026/10/introduction-to-databricks-terraform.html">Introduction to the Databricks Terraform exporter</a>, I described what the Databricks Terraform exporter does and how to use it. This post describes one specific part of the implementation: how the exporter produces code in which resources refer to each other correctly, so it makes the exported code portable.</p><h3>Why generation of correct references is hard</h3><p>Databricks resources may point to other. A group has members, a job triggers a dashboard update, a secret belongs to a secret scope, and a cluster policy can embed several secrets. When you read these objects through the API, each reference is a raw identifier: a group SCIM ID, a dashboard unique ID, or a secret scope name.</p><p>If the exporter wrote those raw identifiers into the generated code, the code would not be portable. The identifiers are often unique to the source workspace, and applying the configuration in another workspace would either fail or point at the wrong object. What you want instead is code in which one resource refers to another via a Terraform reference, for example, databricks_group.my_group.id. Terraform then resolves the reference at apply time, in the target workspace, and the dependency stays correct. Plus, when you create resources, you need to create them in the correct order, like the dashboard should be created before a job - if you don&#39;t have references between resources, Terraform will attempt to create many of them in parallel, leading to creation errors.</p><p>Referential integrity is the property that makes this work: the generated configuration correctly represents the state of the source, and applying it produces an equivalent workspace object. The rest of this post describes how the exporter does it.</p><h3>Declaring dependencies</h3><p>Each resource type in the exporter is described by an <a href="https://github.com/databricks/terraform-provider-databricks/blob/main/exporter/model.go#L134">importable structure</a>. Its Depends field lists the references that the resource has to other resources. For example, databricks_group_member connects a group to one of its members (it could be a user, service principal, or another group), so it declares references for both fields:</p><pre>&quot;databricks_group_member&quot;: {<br>    Service: &quot;groups&quot;,<br>    Depends: []reference{<br>        {Path: &quot;group_id&quot;, Resource: &quot;databricks_group&quot;},<br>        {Path: &quot;member_id&quot;, Resource: &quot;databricks_user&quot;},<br>        {Path: &quot;member_id&quot;, Resource: &quot;databricks_group&quot;},<br>        {Path: &quot;member_id&quot;, Resource: &quot;databricks_service_principal&quot;},<br>    },<br>},</pre><p>Each reference says which field holds the value (Path), which resource type the value points at (Resource), and, optionally, how to match it. By default, the match is based on the target resource&#39;s ID (the id attribute), but you can match on another attribute using the Match field and choose a match strategy with MatchType (see below). A reference can also point to a generated file or a variable instead of another resource.</p><p>The same path can appear more than once. member_id points to a user, a group, or a service principal, so the exporter declares one reference per possible type; the resolver tries them in order until one matches. This is common because a Databricks field often accepts several kinds of objects (but all of them should have the same type).</p><p>Most resources define only a few references, but some require many more. The dependency list for databricks_job is about 200 lines with more than 100 individual references, covering task notebooks, libraries, clusters, cluster policies, warehouses, notification destinations, referenced jobs, etc. That list doesn&#39;t even include the for_each task variants: the exporter generates those from the base list by rewriting each task path, rather than writing them out by hand. So while the mechanism is relatively simple, the reference definitions for some resources can be complex.</p><h3>Two passes: approximate, then resolve</h3><p>The exporter cannot resolve a reference while it imports a single resource, because the target resource may not have been imported yet. The reference is resolved in two passes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bkcG1VgmhIyTux0-a1WM6g.png" /></figure><p>During listing and import, the exporter reads each resource and adds it to an in-memory “state approximation”. This approximation is the set of resources it has already seen, grouped by resource type and indexed by attribute value, so that a lookup by resource type, attribute, and value is fast. All listing and import must finish before code generation starts, because reference resolution needs the complete set of resources. This is the same barrier described in the <a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">performance post</a>.</p><p>During code generation, the exporter walks each resource’s fields. For a field that has a matching reference in Depends, it takes the field value, looks up a matching resource in the state approximation (not always only there), and replaces the raw value with a Terraform reference to that resource.</p><p>With the initial pass, the intermediate state holds resources with raw identifiers, approximately like this (really, admins is generated as a data source, not as a resource):</p><pre>resource &quot;databricks_group&quot; &quot;admins&quot; { // ID: abc<br>  display_name = &quot;admins&quot;<br>}<br><br>resource &quot;databricks_user&quot; &quot;jane&quot; { // ID: def<br>  user_name = &quot;jane@example.com&quot;<br>}<br>resource &quot;databricks_group_member&quot; &quot;admins_jane&quot; {<br>  group_id  = &quot;abc&quot;<br>  member_id = &quot;def&quot;<br>}</pre><p>After the resolution pass, the raw identifiers are replaced with references:</p><pre>resource &quot;databricks_group_member&quot; &quot;admins_jane&quot; {<br>  group_id  = databricks_group.admins.id<br>  member_id = databricks_user.jane.id<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v2aJw1lBGJRS2qM-kCIzIA.png" /></figure><h3>How the lookup works</h3><p>The state approximation keeps, for each resource type, both a list of resources and a map from an attribute-and-value pair to the resource that has it. This map gives a direct lookup for most cases. When the exporter resolves a reference with an exact, case-insensitive, or regular-expression match, it first tries this direct lookup by the wanted attribute and value. If the direct lookup misses or the match is a prefix match, it falls back to iterating over all resources of the target type and comparing the attribute (which can be very slow for large workspaces with many notebooks, etc.).</p><p>Two conditions can reject a candidate that otherwise matches:</p><ul><li>The candidate resource is ignored (for example, filtered out of the export).</li><li>The reference defines an IsValidApproximation function that returns true or false. This is used when a match on one attribute is not enough. For example, each Unity Catalog catalog has a default schema, and matching only on the schema name could point at the wrong catalog, so the corresponding IsValidApproximation function in databricks_schema implementation checks the parent catalog as well.</li></ul><p>During an iterative search, the resolver continues to the next candidate after either rejection. An exact or regular-expression lookup normally uses the direct map and has only one candidate. Exact references that need to inspect several candidate sets SkipDirectLookup to true, which forces the iterative search.</p><p>When a match is found, the exporter emits a Terraform traversal for it, such as databricks_group.my_group.id. If the matched resource was generated as a data source rather than a managed resource, the reference points at the data source instead. This happens, for example, when Identity Federation is enabled, and account-level users, service principals, and groups are generated as data sources.</p><h3>Match strategies</h3><p>The default match compares the whole value against the target attribute. Some references need a different comparison, selected with MatchType:</p><ul><li>exact (the default): the value must match the attribute as-is.</li><li>caseinsensitive: the value must match, ignoring character case (i.e., a user name).</li><li>prefix: the attribute is a prefix of the value. A Databricks Git Folder path, for example, is a prefix for the notebook paths inside it.</li><li>longestprefix: like prefix, but when several candidates match, the one with the longest matching prefix is chosen.</li><li>regexp: a regular expression (in the Regexp field) extracts a match group from the value, and that match group is used for the lookup.</li></ul><p>By default, all strategies except longestprefix stop at the first valid candidate. The search usually continues past a candidate only when it is ignored or when IsValidApproximation rejects it. Or there could be multiple references in a single value, as described in the next section.</p><p>In some cases, we even need to transform a value before the match. I.e., if we have two directories with the same prefix, i.e., /Shared/test and /Shared/test2, and the value to search is /Shared/test2/** (i.e., in SDP configuration) - we can&#39;t stop after a first prefix match because it will be wrong - we need to make sure that we match the second variant. To support such relatively rare cases, we allow specifying a transformation function as part of the reference struct. The name of the function is specified by the SearchValueTransformFunc field.</p><h3>References embedded in a larger value</h3><p>A reference is not always the whole value of a field. With a prefix or regexp match, only part of the string is a reference, and the rest is literal text. In that case, the exporter substitutes only the matched part and keeps the surrounding text, producing an interpolation such as &quot;${databricks_repo.my_repo.path}/notebook&quot;.</p><p>Two reference options handle values that contain more than one reference:</p><ul><li>ContinueMatch tells the resolver not to stop after the first match, so multiple references to the same field are evaluated, and their substitutions are combined into a single value. This is used, for example, for a value that embeds both a variable for account ID and a resource reference.</li><li>MultiMatch, together with a regexp match, substitutes every occurrence of the expression in the value, resolving each captured group to its own target. This is used for values that contain multiple references, such as multiple {{secrets/scope/key}} traversals within a cluster policy definition.</li></ul><h3>References to files</h3><p>Some fields point at a file rather than another resource, which is common for notebooks, workspace files, libraries, init scripts, and some other resources. The resource’s Import function downloads the file into the export directory. A reference with File: true then rewrites the field to point at that local copy. For example, databricks_file declares:</p><pre>&quot;databricks_file&quot;: {<br>    Service: &quot;storage&quot;,<br>    // ...<br>    Depends: []reference{<br>        {Path: &quot;source&quot;, File: true},<br>        {Path: &quot;path&quot;, Resource: &quot;databricks_volume&quot;, Match: &quot;volume_path&quot;, <br>            MatchType: MatchLongestPrefix},<br>    },<br>},</pre><p>and the exporter downloads the file and generates:</p><pre>resource &quot;databricks_file&quot; &quot;main_default_init_scripts_empty_init_script_sh_866a123d&quot; {<br>  source = &quot;${path.module}/uc_files/main/default/init-scripts/empty_init_script.sh&quot;<br>  path   = &quot;${databricks_volume.main_default_init_scripts.volume_path}/empty_init_script.sh&quot;<br>}</pre><p>The ${path.module}/ part stays a real Terraform interpolation, and the file name, which comes from the object path, is escaped so that it cannot break out of the string.</p><h3>References to variables</h3><p>Some values should not be hard-coded in the generated code, most importantly, secrets, which the API doesn’t return by default. A reference with Variable: true makes the exporter declare a Terraform variable for the field and reference that variable from the resource. For example, databricks_secret declares:</p><pre>Depends: []reference{<br>    {Path: &quot;string_value&quot;, Variable: true},<br>    {Path: &quot;scope&quot;, Resource: &quot;databricks_secret_scope&quot;},<br>},</pre><p>The exporter generates a variable declaration in the vars.tf:</p><pre>variable &quot;string_value_builtin_test1_dfd372d494&quot; {<br>  description = &quot;&quot;<br>}</pre><p>and put a reference to it into the resource:</p><pre>resource &quot;databricks_secret&quot; &quot;builtin_test1_dfd372d494&quot; {<br>  string_value = var.string_value_builtin_test1_dfd372d494<br>  scope        = databricks_secret_scope.builtin_748a336cc8.id<br>  key          = &quot;test1&quot;<br>}</pre><p>You should set the variable value before you apply, or, in the case of secrets, if you pass -export-secrets, the exporter writes the current values to terraform.tfvars file.</p><p>By default, each variable reference gets a unique name derived from the field. A reference can set VariableName to share a single variable across multiple resources. The account ID is a good example: account-level exports declare a single databricks_account_id variable and reference it from the provider block and from every account-scoped attribute, so you change the target account by editing a single value.</p><h3>When this matters</h3><p>For some exports, you may not notice any reference resolution: the generated code just works when applied to the same workspace. It becomes important when you move resources between workspaces, merge exports from several workspaces, or run repeated exports for a migration or a disaster recovery. In those cases, the difference between a raw identifier and a proper reference is the difference between a configuration that applies cleanly in the target workspace and a configuration that does not. The exporter resolves the references so that the generated code stays portable.</p><p><a href="https://alexott.blogspot.com/2026/10/inside-databricks-terraform-exporter.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5f3f7cea3428" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introduction to the Databricks Terraform exporter]]></title>
            <link>https://medium.com/@alexott_en/introduction-to-the-databricks-terraform-exporter-643fe30c0c9c?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/643fe30c0c9c</guid>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[databricks]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Thu, 01 Oct 2026 07:52:00 GMT</pubDate>
            <atom:updated>2026-10-01T07:52:00.673Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>The initial version of this blog was written a few years ago, together with </em><a href="https://www.linkedin.com/in/smertin/"><em>Serge Smertin</em></a><em>, who was the initial author of this functionality. This post is really a prequel to the </em><a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html"><em>previous post about speeding up the exporter</em></a><em>.</em></blockquote><p>We need reproducible environments for our data and ML projects. We want to keep the platform available (with high availability and disaster recovery), promote data and ML pipelines safely across stages (dev/staging/prod), and let teams create new workspaces and resources without manual setup when needed.</p><p>Such environments are usually described with an Infrastructure-as-Code (IaC) tool, and Terraform is one of the widely adopted tools in that class. It uses the same language to describe the cloud infrastructure and to integrate it with other systems, including Databricks objects such as clusters, users, jobs, notebooks, and almost everything else in a workspace. (Yes, we have Databricks Automation Bundles (DABs) now, and they could be more convenient to data and ML engineers, but Terraform isn’t going away. I wrote about <a href="https://alexott.blogspot.com/2024/08/terraform-vs-databricks-asset-bundles.html">my position on Terraform vs. DABs</a> almost two years ago.</p><p>The <a href="https://registry.terraform.io/providers/databricks/databricks/latest">Databricks Terraform provider</a> is used by many customers for various tasks: <a href="https://databricks.com/blog/2022/04/25/disaster-recovery-overview-strategies-and-assessment.html">implementing disaster recovery</a> (before managed disaster recovery becomes available), building <a href="https://databricks.com/session_na21/giving-away-the-keys-to-the-kingdom-using-terraform-to-automate-databricks">reusable project templates</a> for mesh product teams, promoting projects across environments, etc. Many customers who set up Databricks through the UI understand the benefits of IaC, but they worry they will have to re-implement everything in Terraform. That is a large task when there are many teams and many workspaces, and they have many objects inside them.</p><p>This post describes the <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter">Terraform exporter</a> included in the Databricks Terraform provider. The exporter generates Terraform code for the resources that already exist in a workspace or a Databricks account, so you can start using Terraform without writing everything by hand. You can use it to manage an existing workspace, perform a full or partial migration between workspaces, or just back up the definitions of your deployed assets.</p><p>The exporter is built and maintained by the field team rather than by product engineering, and although it’s marked as experimental, many customers use it, sometimes at extreme scale (see <a href="https://alexott.blogspot.com/2024/12/working-with-huge-terraform-states.html">Working with huge Terraform states</a>). However, its interface or output can change or be removed in a future provider version, so we keep the experimental label.</p><h3>Quickstart</h3><p>Getting started with the exporter takes a few steps:</p><ul><li>Get the Databricks Terraform provider binary. Download the latest version for your operating system from the <a href="https://github.com/databricks/terraform-provider-databricks/releases">releases page</a>, or reuse the binary that Terraform already downloaded for one of your projects (check the ~/.terraform.d/plugin-cache/registry.terraform.io/databricks directory, but make sure you use the latest version).</li><li>Set environment variables for authentication. Exporter picks any <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs#authentication">supported environment variable</a>. The simplest way is to use the DATABRICKS_CONFIG_PROFILE variable with the name of the Databricks CLI profile. For account-level operations, you may need to set the DATABRICKS_ACCOUNT_ID variable as well.</li><li>You can run it with just the terraform-provider-databricks exporter command and select the resources to export interactively. Or you can skip the interactive selection (most suitable for regular exports), by passing -skip-interactive and specifying the resource types through the -listing and -services options (in recent versions, the exporter also switches to non-interactive mode automatically when you pass -listing or -services, so you don&#39;t need to specify that option).</li><li>Wait for the export to finish. The time depends on the number of resources (you can tune parallelism, but for most workspaces, the defaults are good enough). If you want to see what the exporter is doing, add the -debug option (or -trace for even more detail). These options include Databricks SDK HTTP logs, so review where you store and share the output, as it may contain sensitive data from API requests or responses.</li><li>Look for the generated files in the output directory (see screenshot). If necessary, set values for the variables declared in vars.tf, for example, secrets in the secret scope or secrets for mounts (mounts are deprecated anyway).</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/921/1*tL-jfi1IAGblJZZzrk2tFA.png" /></figure><p>So now you can start using the generated code:</p><ul><li>It’s recommended to review all generated files before you use them.</li><li>If you export resources to start managing the same workspace, use the same identity for the export and the terraform apply. Otherwise, Terraform can change the ownership of some objects. The exporter has been tested most extensively with administrator permissions (workspace, metastore and and account) - exports made with more limited permissions can be incomplete.</li><li>To productionize the generated files, apply your own coding standards and use the correct <a href="https://www.terraform-best-practices.com/examples">repository structure</a>. The exporter aims for readable output (although resource names may contain UUIDs or numbers, making them less readable), so you may want to apply final touches — the VSCode or IntelliJ Terraform extensions can help with refactoring of the generated code. This is not required if you want to perform periodic synchronization between different workspaces.</li><li>To manage the existing workspace with the generated code, it’s recommended to run the exporter with the -native-import option. After the export, run terraform init, and then plan and apply. This imports the resources into the <a href="https://www.terraform.io/language/state">Terraform state</a> through native import {} blocks (which need Terraform 1.5 or later). You can also run the generated ./import.sh script (i.e., you have an old Terraform version), but it is much slower than using the import blocks.</li></ul><h3>Selecting what to export</h3><p>By default, the exporter processes all services, which is rarely what you want for a large workspace. There are two options to control the scope, and it helps to keep them apart:</p><ul><li>-listing selects the services whose objects are discovered and imported. This is the option to reach for most of the time. Please note that some resources aren&#39;t listable directly as they are attached to other objects - i.e., permissions on notebooks, clusters, and other workspace objects, or schemas in UC catalogs, or tables/volumes/… in those schemas. Such objects are typically emitted as dependencies of their parent objects.</li><li>-services controls which transitive dependencies are processed, for example, the permissions of a listed object, or a notebook/dashboard/alert referenced by a job.</li></ul><p>Both options take a comma-separated list of service names. (The full list of available services can be found in the <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter#services">documentation</a>.) You can start with all (that&#39;s a default), and add a service with +service (it&#39;s the same as service), or remove one with -service. There is a special alias uc that selects all Unity Catalog-related services, including vector-search.</p><p>For example, -listing all,-uc lists everything except Unity Catalog, and -listing compute,jobs lists only clusters and jobs, and exports them together with all their dependencies.</p><p>By default, -services include every service, so all discovered dependencies are generated as portable references. When you restrict -services, the exporter still lists the objects selected by -listing, but it only emits resources for the services you explicitly specified. References to the services you drop could be written as hard-coded IDs instead (i.e., job or cluster IDs), which may not be portable between workspaces. This is useful when part of the environment is managed separately. For example:</p><ul><li>-listing compute,jobs -services all,-users,-groups exports clusters and jobs with their dependencies, but does not generate databricks_user or databricks_group resources, because the identities are managed elsewhere.</li><li>-listing jobs -services notebooks,storage,uc-volumes exports jobs together with only their notebooks and the files on storage, and leaves other dependencies (SQL objects, permissions, and so on) as hard-coded IDs.</li></ul><p>You can also filter by resource name during the listing:</p><ul><li>-match keeps resources whose name contains a given string (case insensitive).</li><li>-matchRegex keeps resources whose name matches a regular expression.</li><li>-excludeRegex drops resources whose name matches a regular expression.</li></ul><p>These name filters apply during listing to most (but not all) resource types, so combine them with -listing. If you want the dependencies of a single cluster, list only the compute service (-listing compute) and match that cluster by name. -excludeRegex has higher priority than -match and -matchRegex, so anything matching it will be skipped. Directory names are not filtered by default during the workspace tree walk; pass -filterDirectoriesDuringWorkspaceWalking if you want the filters to apply to directories too (be careful, because it then applies to all directories, so you may need to allow parent paths such as /Users in the regex).</p><p>The -prefix option is not a filter: it adds a prefix to the names of all exported resources. This is useful when you merge exports from several workspaces into a single export and want to avoid name collisions.</p><h3>Commonly used options</h3><p>The exporter has more options than most runs need. These are the ones you are most likely to use:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*DeEc6ClEFBOagGHPgW5h2w.png" /></figure><p>The exporter has more options than this, for example, to control the notebook export format or how to handle the user name when generating resource names. See the <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter">guide</a> for the full list.</p><p>A few examples of command lines to run the exporter:</p><pre># Export only jobs and clusters and their dependencies into ./export<br>terraform-provider-databricks exporter -skip-interactive \<br>  -listing compute,jobs -directory ./export<br># Export jobs and clusters, but skip user and group resources<br>terraform-provider-databricks exporter -skip-interactive \<br>  -listing compute,jobs -services all,-users,-groups<br># List everything except Unity Catalog, with native import blocks.<br># UC dependencies could be still exported if they are dependencies<br># of other resources.  To skip them as well, use `-services all,-uc`<br>terraform-provider-databricks exporter -skip-interactive \<br>  -listing all,-uc -native-import<br># Export one team&#39;s resources, matched by a name prefix, for a merge<br># with other exports<br>terraform-provider-databricks exporter -skip-interactive \<br>  -matchRegex &#39;^team_a_&#39; -prefix team_a</pre><h3>What the exporter produces</h3><p>The exporter writes several files into the output directory (see the screenshot above as well):</p><ul><li>databricks.tf holds the provider declaration.</li><li>One .tf file per service, for example, compute.tf, jobs.tf, and storage.tf, storing the generated resources.</li><li>vars.tf declares the variables (if they are needed) used by the generated resources, and terraform.tfvars holds secret values when you pass -export-secrets.</li><li>import.sh contains terraform import commands, and import.tf contains native import {} blocks when you pass -native-import.</li><li>exporter-run-stats.json records the run time and resource counts, and is also used to drive incremental exports.</li><li>ws_objects.json is a snapshot of workspace objects - it&#39;s used to detect deletions in incremental mode.</li><li>ignored_resources.txt lists the resources that were skipped during code generation, one per line.</li></ul><h3>Handling secrets and variables</h3><p>Normal Terraform resource reads do not expose Databricks secrets in plaintext, so the generated configuration declares a variable for each secret and references that variable in the resource. By default, the variable has no value, and you must fill it in before you run terraform apply. If you pass -export-secrets, the exporter makes a separate Secrets API call and writes the current secret values into terraform.tfvars. Treat that file as sensitive and keep it out of version control.</p><p>The same variable mechanism is used for other values that should not be hard-coded, such as the secret scope and secret names referenced by a mount.</p><h3>Migrating between clouds</h3><p>When you move a workspace between clouds, cloud-specific compute attributes differ. The -targetCloud option (with a value of aws, azure, or gcp) converts compatible attributes for clusters, jobs (both new_cluster in tasks and job clusters), pipelines, instance pools, and cluster policies. These include the availability setting, first_on_demand, and selected disk attributes. For zone_id, only the value auto is portable between AWS and GCP. Incompatible attributes, such as an AWS instance profile ARN, are omitted. Review the generated configuration and manually add any required cloud-specific settings.</p><p>Instance types cannot be converted completely automatically because clouds do not share node type names, so we need to provide a mapping. Pass -nodeTypeMappingFile with a JSON file that maps node types between clouds, and the exporter rewrites node_type_id accordingly. This option can only be used together with -targetCloud.</p><h3>Exporting at the account level</h3><p>The exporter runs in three stages. First, it lists the objects of each selected resource type. Then it imports each object: it reads the object’s current state through the API and, when necessary, emits its dependencies (permissions, a referenced notebook, etc.) and downloads external files such as libraries, init scripts, and notebooks. Finally, it generates the Terraform (HCL) code and writes it to per-service files. The <a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">performance improvement post</a> has a diagram of this flow in its parallel form, where listing, import, and code generation run in worker pools.</p><p>The logic for importing a resource type is kept separate from the logic that generates the actual Terraform code. Each resource type is described by a small declarative structure, which makes the exporter easy to extend as new resources are added to the provider. A separate post will describe that structure, how to add a new resource, and other implementation details.</p><p>One of the harder parts of code generation is referential integrity: making sure that a reference in one resource points to another generated resource, so that applying the code reproduces the source workspace, and doesn’t try to use a hardcoded ID that is unique inside the source workspace. The exporter resolves these references in a separate pass and supports different match strategies (exact, case-insensitive, prefix, and regular expression). A separate post will describe this mechanism. For this post, it’s enough to know that the generated code uses proper Terraform references between resources rather than hard-coded IDs (which is the most common problem in similar projects).</p><h3>How it works under the hood</h3><p>The exporter runs in three stages. First, it lists the objects of each selected resource type. Then it imports each object: it reads the object’s current state through the API and, when necessary, emits its dependencies (permissions, a referenced notebook, etc.) and downloads external files such as libraries, init scripts, and notebooks. Finally, it generates the Terraform (HCL) code and writes it to per-service files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y7acMCme83L1SjTrQCbsjg.png" /></figure><p>The <a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">performance improvement post</a> has a bit more detailed diagram of this flow in its parallel form, where listing, import, and code generation run in worker pools.</p><p>The logic for importing a resource type is kept separate from the logic that generates the actual Terraform code. Each resource type is described by a small declarative structure, which makes the exporter easy to extend as new resources are added to the provider. A separate post will describe that structure, how to add a new resource, and other implementation details.</p><p>One of the harder parts of code generation is referential integrity: making sure that a reference in one resource points to another generated resource, so that applying the code reproduces the source workspace, and doesn’t try to use a hardcoded ID that is unique inside the source workspace. The exporter resolves these references in a separate pass and supports different match strategies (exact, case-insensitive, prefix, and regular expression). A separate post will describe this mechanism. For this post, it’s enough to know that the generated code uses proper Terraform references between resources rather than hard-coded IDs (which is the most common problem in similar projects).</p><h3>Performance and incremental export</h3><p>The first versions of the exporter were single-threaded, and a large workspace with tens of thousands of notebooks could take hours or days to export, mostly waiting on the network. The current implementation lists, imports, and generates code in parallel using worker pools, and it supports incremental export that processes only the resources changed since the previous run. Both are important for repeated exports such as migration cutovers and disaster-recovery synchronization runs. The design, tuning parameters, and the problems introduced by parallelism are described in a separate post, <a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">Speeding up the Databricks Terraform exporter</a>.</p><p>Incremental mode is enabled with -incremental, optionally with -updated-since to set the cutoff timestamp. For resource types whose APIs report a modification time, the exporter lists and processes only resources modified after the cutoff. The caveat is that their dependencies are always exported. Resource types that do not report a modification time are always exported. The exporter tracks the previous run time in exporter-run-stats.json and merges new code into the previous run. See the <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter#support-matrix">support matrix</a> for the resource types that support incremental export. For most resource types, the API does not report deletions, so you need to run a full export from time to time. The workspace objects (notebooks, workspace files, and directories) are the exception, because the exporter tracks them in ws_objects.json and can detect deletions.</p><h3>What the exporter covers</h3><p>The exporter supports resources across compute, jobs, notebooks, and other workspace objects, SQL, Unity Catalog, model serving, and account-level MWS services. The <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter#support-matrix">support matrix</a> in the documentation lists the current set.</p><p>Some resources are intentionally not exported because they do not fit the discovery-and-import model or should not be copied between environments. These include short-lived or credential-like resources such as personal access tokens, service principal secrets, and git credentials, and grants that are better managed together with the objects they apply to. The exporter writes supported resources that it discovers, but aren’t generated during a run to ignored_resources.txt.</p><h3>Similar projects</h3><p>Similar open source projects generate Terraform code for existing infrastructure, but they target public cloud providers rather than Databricks:</p><ul><li><a href="https://learn.microsoft.com/en-us/azure/developer/terraform/azure-export-for-terraform/export-terraform-overview">Azure Export for Terraform</a> (aztfexport) is Azure-focused, actively maintained, and offers a good introduction to this kind of tooling.</li><li><a href="https://github.com/cycloidio/terracognita/">TerraCognita</a>, supports AWS, Google Cloud, Azure, and vSphere.</li><li><a href="https://github.com/GoogleCloudPlatform/terraformer">Terraformer</a>, a multi-cloud tool archived in March 2026 and no longer maintained.</li><li><a href="https://github.com/dtan4/terraforming">Terraforming</a>, an AWS-only tool that was archived in 2022.</li></ul><p>None of these tools supports Databricks resources. Shipping the Databricks exporter inside the provider was a good fit because the provider already holds the schemas for all supported resources.</p><h3>When this is useful</h3><p>The exporter is most useful when you want to adopt Terraform for infrastructure you built by hand, or when you migrate resources between workspaces, including a self-built disaster-recovery setup (now prefer to use managed disaster recovery). You can generate Terraform-managed resources in a few minutes instead of writing them manually, and then manage them with the usual best practices: pull requests, review, and CI/CD with approvals. For large workspaces and repeated exports, combine parallel and incremental export as described in the <a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">performance post</a>.</p><h3>Related blogs</h3><ul><li><a href="https://databricks.com/blog/2022/06/22/databricks-terraform-provider-is-now-generally-available.html">Databricks Terraform Provider Is Now Generally Available</a></li><li><a href="https://databricks.com/blog/2022/05/09/how-illimity-bank-built-a-disaster-recovery-strategy-on-the-lakehouse.html">illimity Bank’s Disaster Recovery Strategy on Databricks Lakehouse</a></li><li><a href="https://alexott.blogspot.com/2024/12/working-with-huge-terraform-states.html">Working with huge Terraform states</a></li></ul><p><a href="https://alexott.blogspot.com/2026/10/introduction-to-databricks-terraform.html">Cross-post from the blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=643fe30c0c9c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Speeding up the Databricks Terraform exporter]]></title>
            <link>https://medium.com/@alexott_en/speeding-up-the-databricks-terraform-exporter-3c59cad45000?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/3c59cad45000</guid>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[databricks]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 28 Sep 2026 08:53:33 GMT</pubDate>
            <atom:updated>2026-09-28T08:53:33.087Z</atom:updated>
            <content:encoded><![CDATA[<p>The Databricks Terraform provider includes a <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter">Terraform exporter</a> component. It generates Terraform configuration (.tf files) for the resources it finds (plus an import script) in an existing workspace or Databricks account. This allows users to adopt Terraform for infrastructure they built by hand and migrate resources between workspaces, including for self-built disaster recovery (before Databricks introduced managed disaster recovery). /Note: don&#39;t be afraid of its <strong>experimental</strong> status - it&#39;s used by dozens of customers, sometimes at extreme scale, i.e., as it was described in the blog post <a href="https://alexott.blogspot.com/2024/12/working-with-huge-terraform-states.html">Working with huge Terraform states</a>.</p><blockquote><em>Terraform exporter was originally created by </em><a href="https://www.linkedin.com/in/smertin/"><em>Serge Smertin</em></a><em>, but it’s mostly supported by me in the last ~4 years.</em></blockquote><p>The initial implementation was single-threaded. It listed each resource type one after another, and for every resource, it did the API calls to read the data, and then generated the Terraform code (HCL) at the end. This was very simple to reason about in terms of existing functionality and code correctness, but it didn’t scale. A large workspace can contain tens of thousands of notebooks, hundreds of jobs, many SQL queries, dashboards, their permission, etc. For such workspaces, the export ran for hours or even days, and most of that time was spent waiting on the network, not on the CPU (plus, we need to account for rate limiting on the underlying Databricks REST APIs).</p><p>This post describes two things we did to speed up the exporter. The first is the parallelism: we reworked the listing, the import (reading resource data), and the code generation to run concurrently. The second is incremental export: for repeated exports of the same workspace, such as migration cutovers or self-made disaster recovery, the exporter can process only the resources that were created or changed since the last run. I’ll also touch on the problems introduced by parallelism and incremental export, and how you can tune the exporter’s behavior for a specific run to workaround them.</p><h3>The single-threaded version was slow</h3><p>The TF exporter does three things:</p><ul><li>Listing: for every resource type, query the API to find which resources of a given type exist and emit a resource instance (this will trigger an Import operation if the resource isn’t imported yet).</li><li>Import: for each resource identified by the listing operation (or emitted by another resource), read its full definition through one or more API calls (usually it’s a call to a corresponding ReadContext of the Terraform resource), and emit references to other resources if they exist (i.e., a job may refer to a dashboard, users in notifications, notebook, etc.).</li><li>Code generation: turn the collected state into HCL and write it to files.</li></ul><p>The first two are dominated by REST API latency. A single request to read a notebook or a job takes a fraction of a second, but the exporter does one or more requests per resource, and it does them one after another. With 20000 notebooks, even 100 milliseconds per notebook adds up to more than 30 minutes for that resource type alone, and the CPU is idle the whole time. And we should take into account that the Databricks platform has a limit on the number of REST API calls per second, and it may vary per specific service — i.e., SCIM API v1 had a very small limit, like 2–3 calls per second only.</p><p>The listing of workspace objects was the worst case. Notebooks, directories, and workspace files live in a tree, and the exporter should walk that tree recursively, listing one directory at a time. A deep folder structure turned into a long chain of sequential API calls.</p><p>Three years ago, more and more large migrations and DR implementations were coming, so it was decided to invest time in speeding up the exporter by focusing on two major areas: incremental export of resources (very useful for DR implementations) and parallelization of all operations (all written manually). I’ll start the description with parallelization.</p><h3>The design: channels and worker pools</h3><p>The rework switched the exporter to a producer-consumer model built on Go goroutines and channels. The idea is standard for Go: producers put work items on a channel, and a fixed pool of worker goroutines takes items off the channel and processes them concurrently. The number of workers bounds how many REST API calls run at the same time, which is important because the REST API will throttle a client that sends too many requests.</p><p>The exporter has two heavy phases, and each has its own pool-based implementation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qcvrTH1IRctMQ8aB9IbDHA.png" /></figure><p>The top half of the diagram is the listing and import phase. The bottom half is the code generation phase. The two phases are separated by a barrier: all listing and import operations must finish before code generation starts, because the code generator needs the complete set of resources to resolve references between them.</p><h3>Step 1: parallel listing of workspace objects</h3><p>The first change (PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/2691">#2691</a>, September 2023) started with improving the recursive tree walk. We added a parallel listing routine for workspace objects. A buffered channel holds directories that still need to be listed. A pool of worker goroutines takes a directory off the channel, lists its contents, appends the results to a shared slice guarded by a mutex, and pushes any child directories back onto the channel. A sync.WaitGroup counts the directories that are still in flight, so the pool knows when the whole tree has been walked, and it can stop.</p><p>Two parameters were introduced here (overridable via environment variables):</p><ul><li>EXPORTER_WS_LIST_PARALLELISM (default 5) sets the number of listing workers.</li><li>EXPORTER_DIRECTORIES_CHANNEL_SIZE (300k by default) sets the channel capacity. The channel must be able to hold every directory in the workspace. If it fills up, a worker who tries to push a child directory blocks, and because all workers can block at the same time, the export deadlocks. A large buffer avoids this.</li></ul><p>This step also added retries. A directory listing that fails is re-enqueued and retried up to 3 times, with a short pause between submissions to avoid hitting the API too hard. Retries matter more when the client sends many requests simultaneously, and previously transient errors become more frequent under load.</p><h3>Step 2: parallel import of resources</h3><p>The next change (PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/2742">#2742</a>) made the import of individual resources parallel. Before this, the code that listed a resource type also read each resource inline, in the same loop.</p><p>We split this into two operations. Listing now only discovers resource IDs and emits them. The emit step puts each resource on a channel — it no longer reads the resource data itself. A separate function, ImportResource, does the reading - the search by non-ID attribute (if required), the ReadContext call, and any custom import logic. This function runs inside the worker goroutines that drain the resource channels.</p><p>Different resource types need different amounts of concurrency. Reading a notebook is cheap and safe to do many at a time; while reading users and service principals should stay at a low concurrency to avoid throttling that API. So the number of workers is set per resource type, through a map in the code (goroutinesNumber), with a default of 2 workers when a resource type is not explicitly specified. You can override any of these with an environment variable named after the resource, for example:</p><pre>export EXPORTER_PARALLELISM_databricks_notebook=10<br>export EXPORTER_PARALLELISM_databricks_permissions=11</pre><p>The current defaults reflect what we found safe in practice: 10 workers for notebooks, 11 for permissions, 5 for directories and workspace files, and 1 for users and service principals.</p><h3>Step 3: routing to dedicated and shared channels</h3><p>Giving each resource type its own channel and pool proved being wasteful. Many resource types have just a few instances, so a dedicated pool for each adds overhead without improving performance. A later change (PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/3167">#3167</a>) introduced the routing step shown in the diagram.</p><p>Only a few high-volume resource types get a dedicated channel. By default, these are databricks_user, databricks_service_principal, and databricks_group, because their APIs throttle aggressively and benefit from a controlled, separate queue. Every other resource goes to a single shared channel, called the default channel, served by its own pool of workers (EXPORTER_PARALLELISM_default, default 15). You can change which resources get a dedicated channel with the EXPORTER_DEDICATED_RESOURCES_CHANNELS environment variable by providing a comma-separated list of resources.</p><p>The same PR also made code generation parallel, which is the bottom half of the diagram.</p><h3>Step 4: parallel code generation</h3><p>Code generation had the same problem as import: it processed resources one at a time and wrote all files at the end. Generating the HCL for one resource includes resolving references to other resources, which could be quite expensive, i.e., for workspace objects, so this stage was also slow on large exports.</p><p>We replaced it with another pool. A configurable number of worker goroutines (EXPORTER_RESOURCE_GENERATORS, default 50) each take a resource, generate its HCL body, and send the result to a writer. Writing is separated by a service: one writer goroutine per service owns one output file (for example, clusters.tf or jobs.tf) and is the only goroutine that writes to it. This avoids locking on the file while still generating many resources at once. A separate goroutine writes the shell script with terraform import commands.</p><p>A later change (PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/3293">#3293</a>) added a second import writer. With the -native-import option, the exporter also generates Terraform native import {} blocks (needs Terraform 1.5 and later), and these are written into import.tf file by their own goroutine, reading from a separate channel, next to the shell-script writer.</p><h3>The problems parallelism exposed</h3><p>Parallelism gets code executed faster, but it exposed issues that a single thread never had. Three of them shaped the final design.</p><p><strong>Apply contention in the provider.</strong> Even when notebooks were applied in parallel, the terraform apply was still slow because the databricks_notebook resource held a mutex around directory creation, which serialized the work again. PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/2902">#2902</a> fixed this by creating the parent directory only when a notebook or workspace file import fails because the directory is missing, instead of always locking and calling the create-directory API. This is a fix in the provider resource, not in the exporter, but the exporter is what made the problem visible.</p><p><strong>Channel deadlocks.</strong> A bounded channel can fill up. When the listing produces resources faster than the workers can drain them, the producer blocks on a full channel. If enough goroutines block this way, the export stops making progress. We handle this in two ways: the shared channel has a large buffer (EXPORTER_DEFAULT_HANDLER_CHANNEL_SIZE), and when the channel is more than 80 percent full, the producer pauses briefly before emitting more resources. This is simple backpressure: it slows the producer instead of letting the pipeline deadlock.</p><p><strong>API throttling.</strong> Higher concurrency means more requests per second, and the Databricks REST API will reject requests when a client sends too many of them. The Go SDK automatically retries on HTTP status codes 429, 5xx, etc., but there were still cases where the REST API returned non-standard responses that the Go SDK didn’t handle. In PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/3202">#3202</a>, we added additional retries around the search, read, and import operations, using the same retry approach as the directory listing. The per-resource worker limits were first the line of defense — retries handled the transient errors that still occur.</p><p><strong>Duplicate emits under concurrency.</strong> The Emit function serves as the routing step, and several goroutines call it simultaneously. It marks a resource as “already seen” in a shared map, so the same resource is not imported twice. Large-scale testing found a race: two goroutines could check the map, both find the resource missing, and both enqueue it, resulting in a small number of duplicate resources in the output. PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/4163">#4163</a> fixed this by making the check-and-mark one atomic operation under the mutex, so the first caller wins and the rest skip the resource.</p><h3>Tuning parameters</h3><p>The existing defaults work for most workspaces. For a very large workspace, or one where you see throttling, you can control the behavior with the <a href="https://registry.terraform.io/providers/databricks/databricks/latest/docs/guides/experimental-exporter#parallel-execution">following environment variables</a>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/775/1*DY2m6aOA2VdowcfOqbNL1Q.png" /></figure><p>A word of caution: do not raise the per-resource worker counts too high. The limit that helps in one workspace can cause throttling in another, because the API limits depend on the workspace and the account. Increase the values gradually and watch for throttling errors in the log. In addition to exporter environment variables, you can tune the DATABRICKS_RATE_LIMIT environment variable. <strong><em>But be careful, as the throttling affects not only direct API calls, but also all users of the workspace who use the same APIs when using UI!</em></strong></p><h3>Doing less work: incremental export</h3><p>Parallelism makes a single export faster, but for migrations and disaster recovery projects, you run the export against the same workspace multiple times. In that case, the largest speedup is to not export unchanged resources at all. This was the goal of incremental export. It was added in PR <a href="https://github.com/databricks/terraform-provider-databricks/pull/2563">#2563</a> (August 2023), about a month before the parallel work started, so the two speedups were built independently and can be used together.</p><p>You enable it with two command-line options:</p><ul><li>-incremental turns on incremental mode. The exporter merges the newly generated code with the code from the previous run instead of writing everything from scratch, and it merges the import.sh script, import.tf, and the variables file the same way.</li><li>-updated-since takes an ISO8601 timestamp (for example, 2023-07-24T00:00:00Z) and limits the export to resources modified after that time. If you omit it, the exporter reads the timestamp of the previous run from the exporter-run-stats.json file in the output directory, so a scheduled run needs only -incremental.</li></ul><p>The mechanism relies on a last-modified date, and this is where the limitations come from. Only some resource types report when they were created or modified: notebooks, SQL queries, dashboards, and alerts, among others. For these, the exporter lists them and skips the ones that were not modified since the cutoff. Resource types that do not expose a modification time (jobs, clusters, and many others) are re-exported every time, so incremental mode reduces the work but does not eliminate it for these resource types.</p><p>Deletions are the harder case. For most resource types, the API cannot tell the exporter that a resource was deleted since the last run, so a sequence of incremental runs keeps resources that no longer exist. You must run a full export periodically to catch deletions. Workspace objects (notebooks, workspace files, and directories) are the exception: on each full export, the exporter writes the complete list of workspace objects to a ws_objects.json file, compares against it on the next run to detect deletions, and removes the deleted objects from the generated code.</p><p>Another problem is the renaming of a workspace object that is currently handled as a deletion of the old resource and a creation of a new one. This leads to a situation where in the target workspace, the same resource will get another ID, so resource URLs will change. Potentially, it could be implemented as well by tracking the original resource ID (that doesn’t change on the rename) plus resource name in the generated TF code, and then generating Terraform moved blocks.</p><p>Incremental and parallel export combine well. A daily disaster-recovery export, for example, can run incrementally to skip unchanged notebooks and SQL objects, and still use the parallel worker pools for the resources it processes.</p><h3>When this is useful</h3><p>The parallel exporter implementation was worth the extra complexity when the workspace is large enough that API latency, not CPU, defines the execution time. On a small workspace, the difference is minor, and the defaults are fine. In a workspace with thousands of notebooks, jobs, or queries, parallel listing and import can turn an export that took hours into one that finishes in minutes, and tuning parameters give you room to adapt to a specific workspace’s API limits. For repeated exports, such as migration cutovers and disaster-recovery runs, combine it with incremental mode, which skips resources that have not changed since the previous run.</p><p>The exporter is still marked as experimental (because it’s a tool built by the field team, not engineering), but it’s heavily used by Databricks customers. The design outlined in the diagram has been stable since early 2024: parallel listing that emits resources onto channels, a routing step that separates high-volume resources from the shared queue, per-resource worker pools that read the data, and a code-generation pool that writes per-service files.</p><p><a href="https://alexott.blogspot.com/2026/09/speeding-up-databricks-terraform.html">Cross-post from blogspot</a>…</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3c59cad45000" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Detecting attack sequences in Databricks SQL with MATCH_RECOGNIZE, Part 2]]></title>
            <link>https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-2-de2e19ae9aa7?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/de2e19ae9aa7</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[lakewatch]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 21 Sep 2026 11:14:58 GMT</pubDate>
            <atom:updated>2026-09-21T11:14:58.338Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-1-793822a743ad">Part 1</a> covered the parts of the MATCH_RECOGNIZE clause and demonstrated how to apply it to simple detections: beaconing and impossible travel. This post continues with multi-stage patterns: a process chain, a missing event, and second-order detection that runs over your own alerts rather than raw data. It assumes the clause syntax and the limits from <a href="https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-1-793822a743ad">Part 1</a>.</p><h3>A process chain: the malicious document pattern</h3><p>A classic endpoint intrusion starts when someone opens a malicious document (maldoc): an office application executes code when the file is opened, and that downloads a malware payload via a scripting tool like PowerShell. The signal is in the parent process, so this recipe reads process.parent_process (we extract a field using the : operator because this column has a VARIANT type). Pre-filter to office-spawned processes and script hosts so the two stages sit side by side per host, then match them in order. We use two CTEs: the first flattens the fields into columns, the second filters on those columns:</p><pre>WITH launched AS (<br>  SELECT time,<br>         device.hostname AS host,<br>         process.name AS pname,<br>         process.parent_process:name::string AS parent_name<br>  FROM process_activity<br>  WHERE activity_name = &#39;Launch&#39;<br>    AND time &gt;= current_timestamp() - INTERVAL 1 DAY<br>),<br>proc AS (<br>  SELECT * FROM launched<br>  WHERE parent_name ILIKE &#39;%outlook.exe&#39; OR parent_name ILIKE &#39;%winword.exe&#39;<br>     OR parent_name ILIKE &#39;%excel.exe&#39;<br>     OR pname IN (&#39;powershell.exe&#39;, &#39;cmd.exe&#39;, &#39;wscript.exe&#39;, &#39;cscript.exe&#39;)<br>)<br>SELECT *<br>FROM proc<br>MATCH_RECOGNIZE (<br>  PARTITION BY host<br>  ORDER BY time<br>  MEASURES FIRST(time) AS started,<br>           LAST(time)  AS ended,<br>           COUNT(*)    AS steps<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (office_spawn script+)<br>  DEFINE office_spawn AS parent_name ILIKE &#39;%outlook.exe&#39;<br>                        OR parent_name ILIKE &#39;%winword.exe&#39;<br>                        OR parent_name ILIKE &#39;%excel.exe&#39;,<br>         script       AS pname IN (&#39;powershell.exe&#39;, &#39;cmd.exe&#39;, &#39;wscript.exe&#39;, &#39;cscript.exe&#39;)<br>) AS m;</pre><p>office_spawn matches the process the office application launched (the dropped payload), and script+ matches the script host it runs next. If you want the stages in any order rather than a fixed sequence, PATTERN (PERMUTE(office_spawn, script)) matches the two variables in either order. The same shape works on cloud audit data (api_activity), where you can look for a run of read operations followed by a role assignment write by the same principal.</p><h3>Matching an event that is missing</h3><p>It’s not always only about events that occurred. Some useful signals are about an expected event that is missing: a login that is never confirmed by a step-up, a secret exposed in a repository that no one quarantines, or an administrator role granted without approval. MATCH_RECOGNIZE can match the absence of a follow-up, because NEXT() reads the next row in order, and a DEFINE can require that it is not the event you were hoping for.</p><p>Take multi-factor authentication that a user turns off and is supposed to turn back on for a short period. Read a per-user stream of the two operations (off and on), ordered by time, and match a disable operation where the next event for that user is not a re-enable within an hour:</p><pre>WITH mfa AS (<br>  SELECT time,<br>         user.name     AS user_name,<br>         activity_name AS action<br>  FROM account_change<br>  WHERE activity_name IN (&#39;MFA Factor Disable&#39;, &#39;MFA Factor Enable&#39;)<br>    AND time &gt;= current_timestamp() - INTERVAL 2 DAY<br>  QUALIFY lag(action) OVER (PARTITION BY user_name ORDER BY time)<br>          IS DISTINCT FROM action<br>)<br>SELECT *<br>FROM mfa<br>MATCH_RECOGNIZE (<br>  PARTITION BY user_name<br>  ORDER BY time<br>  MEASURES FIRST(time) AS disabled_at<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (left_disabled)<br>  DEFINE left_disabled AS action = &#39;MFA Factor Disable&#39;<br>     AND (NEXT(action) IS NULL<br>          OR NEXT(action) &lt;&gt; &#39;MFA Factor Enable&#39;<br>          OR unix_timestamp(NEXT(time)) - unix_timestamp(time) &gt; 60 * 60)<br>) AS m;</pre><p>left_disabled matches a disabled event where the next event for that user is missing, is not a re-enable, or is a re-enable that arrived too late.</p><p>The WHERE filter is an important part of this query. It limits data to only two operations, so any other user action performed between disable and enable is removed. That is what allows NEXT() to go from the disabled to the next relevant event. Keep this filter if you reuse the pattern. On an unfiltered activity, NEXT() would read whatever action came next and raise a false alert. To express &quot;ignore everything that is not one of these events&quot;, just remove those rows.</p><p>The QUALIFY line is also part of the work. MFA is a binary switch, not a count: any run of disables in a row leaves it in a one-off state, and a single enable after them turns it back on. So five disables followed by one enable is safe, not five separate problems. The lag(action) IS DISTINCT FROM action check keeps a row only when the action differs from the one before it for that user, which collapses repeated actions into a single row and leaves a clean stream of real state changes. Without it, the first disable in a Disable, Disable, Enable run would see another disable as its next event, not the enable, and raise a false alert even though the account ended enabled. On our test data, omitting this turned 0 real findings into 9 false ones.</p><p>The MFA rule so far tests two things: the order of the events, and the gap between them. Absence has a third part that the pattern cannot see on its own: how long a user account was in a bad state. When a disable is the last event for a user (NEXT(action) IS NULL), you know nothing follows it. You do not know if it happened two minutes ago or two days ago. Wall-clock time is not a row in the stream, so DEFINE cannot test it: DEFINE only compares rows to each other, and there is no row for &quot;one hour of nothing&quot;.</p><p>So you keep the time of the last event as a measure and compare it to current_timestamp() outside the match:</p><pre>SELECT *<br>FROM (<br>  SELECT * FROM mfa<br>  MATCH_RECOGNIZE (<br>    PARTITION BY user_name<br>    ORDER BY time<br>    MEASURES LAST(time) AS disabled_at<br>    ONE ROW PER MATCH<br>    AFTER MATCH SKIP PAST LAST ROW<br>    PATTERN (left_disabled)<br>    DEFINE left_disabled AS action = &#39;MFA Factor Disable&#39;<br>       AND NEXT(action) IS NULL<br>  ) AS m<br>)<br>WHERE disabled_at &lt; current_timestamp() - INTERVAL 1 HOUR;</pre><p>Use the same mfa CTE as before, so the stream contains only the two operations. The pattern shows that the state is still open because the disable is the user&#39;s most recent relevant event. The outer filter proves it has stayed open long enough to matter. You cannot move that filter inside the match, because a match sees only the rows in the partition, not the current clock.</p><p>We use two checks here. The first checks whether B follows A within a specified time. The second checks whether A occurred, no relevant event followed, and enough time has passed. A privileged role granted and never used, a secret exposed and never quarantined, a session that starts and then goes quiet: you match what happened, keep the time of the last event, and let NEXT() and a clock decides what should have come next.</p><h3>Correlating your own detections</h3><p>You can run matches against any data, not necessarily the cybersecurity data. If you run detections in Lakewatch, it could be your own <a href="https://docs.lakewatch.com/threat-detection/notables">notables</a>: the signals produced by your rules. That gives you a second-order detection, one that runs over the output of the other rules instead of over the events underneath them.</p><p>Currently, notables are stored in a table (&lt;lakewatch_catalog&gt;.internal.notables). Each row has a time, a rule_display_name, a severity, and an observables array that links the notable to the extracted entities (an IP address, an asset, an email address, a file hash). Lakewatch already groups notables inside a case, so the similar signals land together. That groups them by identity or entity. It does not look at the order in which the notables arrived.</p><p>Order is what a kill chain is about. A host that fires a discovery alert, then a suspicious-script alert, then a beaconing alert, then a data-staging alert, in that order within a day, is a stronger signal than any one of those alerts alone, and stronger than the same four in a different order. That ordered progression is what MATCH_RECOGNIZE adds on top of the case grouping.</p><p>The observables column is an array, and PARTITION BY takes a plain column, so first explode it and keep only one entity type, so that every notable in a partition is used for the same object. The available observables determine which kind you can use as the key. A rule attaches only the observables it has in its definition, and different rules attach different kinds of observables: the endpoint rules typically use an asset ID (the host), the authentication rules key on a user identity, i.e., email address, and the beaconing rules use an IP address. A chain has to stay inside one of these keys. If you want to cross them, you resolve the observable kinds to a common entity first (for example, map an IP to a host through an inventory table or DHCP leasing history).</p><p>The endpoint rules all carry an asset ID, so align the chain on the host and match the process sequence a hands-on intrusion leaves behind:</p><pre>WITH signals AS (<br>  SELECT n.time,<br>         o.value             AS host,<br>         n.rule_display_name AS rule_name,<br>         n.severity<br>  FROM fe_lakewatch_catalog.internal.notables n<br>  LATERAL VIEW explode(n.observables) t AS o<br>  WHERE o.kind = &#39;Asset ID&#39;<br>    AND n.time &gt;= current_timestamp() - INTERVAL 7 DAY<br>)<br>SELECT *<br>FROM signals<br>MATCH_RECOGNIZE (<br>  PARTITION BY host<br>  ORDER BY time<br>  MEASURES FIRST(time) AS started,<br>           LAST(time)  AS ended,<br>           COUNT(*)    AS stages<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (discovery exec+ exfil)<br>  DEFINE discovery AS rule_name = &#39;Discovery Command Execution via Direct or Wrapped Process&#39;,<br>         exec      AS rule_name = &#39;suspicious_powershell_scriptblock&#39;,<br>         exfil     AS rule_name = &#39;Suspicious File Staging and Potential Data Exfiltration&#39;<br>                      AND unix_timestamp(time) - unix_timestamp(FIRST(time)) &lt;= 24 * 60 * 60<br>) AS m;</pre><p>Every notable in a partition belongs to the same host, because the explode kept only the asset ID observable. Each row variable is one stage of the chain, named by the rule that produced it, and the window check on the last stage keeps the whole match inside 24 hours. The output is one row per host that matched the full sequence, with the start and end times and the number of stages. In other cases, you would write this as a Splunk ES correlation search or a chain of self-joins in Databricks SQL — here it is one clause. A user-centric chain has the same shape: key on o.kind = &#39;Email Address&#39; and match a sequence of the authentication rules (an off-hours login, then a novel-country login, then data staging by the same user). The rule names in both cases are only examples: use the names of your own rules.</p><p>This works because the whole chain sits within a single entity: one partition, one ordered path. A correlation that crosses entities, a notable on a host tied to a notable on a different user, is a path between partitions, which is more like a graph problem that we described in <a href="https://alexott.blogspot.com/2026/09/graph-analytics-for-cybersecurity-data.html">another post</a>.</p><h3>When to use GraphFrames instead</h3><p>If you have used graph analytics on security data (see the <a href="https://medium.com/@alexott_en/graph-analytics-for-cybersecurity-data-in-the-lakehouse-4d6528cc5a34">graph analytics post</a>), the examples in these two posts may look like graph problems. Some of them are, and some are not, and the difference is worth being clear about.</p><p>MATCH_RECOGNIZE works for an ordered path inside a partition. It cannot follow an edge into another partition. So it fits any detection where the &quot;graph&quot; is really a per-entity timeline: the events of a single identity, entity, or connection, read in a specific order. Those cases become plain SQL that runs in a warehouse or a scheduled detection rule, with no separate graph library and no PySpark.</p><p>Real graph traversal is a different problem. Classic lateral movement, where an account moves from host A to host B to host C and each hop is a different pair of machines, is a path across entities, so you cannot express it with a single partition. Blast-radius questions (“what can this compromised host reach?”) and community detection (“which accounts and hosts cluster together?”) are also graph work. Graph frameworks and algorithms are the better fit for those cases.</p><p>But they both work well together. Use MATCH_RECOGNIZE for the temporal reduction: turn raw events into sessions, into edges, or into &quot;A then B&quot; facts. Then hand only the multi-hop part to GraphFrames. This keeps most of the logic in SQL and gives the graph engine a much smaller graph to work on.</p><h3>Wrap up</h3><p>How much MATCH_RECOGNIZE improves depends on the pattern. The simplest ones have a standard SQL equivalent: impossible travel (<a href="https://alexott.blogspot.com/2026/09/detecting-attack-sequences-in.html">part 1</a>) is a single LAG() comparison, and a query such as fail{5,} success can be rewritten with window functions and a gaps-and-islands trick. Once a pattern has several variable-length stages, an alternation, or any-order PERMUTE, the plain-SQL version needs recursive CTEs or procedural code, because window functions are not recursive and cannot follow an arbitrary-length sequence of stages. In these cases, MATCH_RECOGNIZE expresses the sequence more easily than nested joins. For fixed two-row comparisons, a window function can remain a simpler alternative. Use row pattern matching when the order, repetition, or optional stages are important in the detection.</p><h3>References</h3><ul><li>Databricks docs: <a href="https://learn.microsoft.com/en-us/azure/databricks/sql/language-manual/sql-ref-syntax-qry-select-match-recognize">MATCH_RECOGNIZE clause</a> and the <a href="https://learn.microsoft.com/en-us/azure/databricks/sql/language-manual/sql-ref-syntax-qry-select-match-recognize-pattern">PATTERN sub-clause</a>.</li><li><a href="https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-1-793822a743ad">Part 1: the parts of the clause, beaconing, and impossible travel</a>.</li><li><a href="https://medium.com/@alexott_en/graph-analytics-for-cybersecurity-data-in-the-lakehouse-4d6528cc5a34">Graph analytics on cybersecurity data</a>.</li><li><a href="https://docs.lakewatch.com/">Lakewatch documentation</a>.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=de2e19ae9aa7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Detecting attack sequences in Databricks SQL with MATCH_RECOGNIZE, Part 1]]></title>
            <link>https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-1-793822a743ad?source=rss-74ef63c7f56e------2</link>
            <guid isPermaLink="false">https://medium.com/p/793822a743ad</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[lakewatch]]></category>
            <dc:creator><![CDATA[Alex Ott]]></dc:creator>
            <pubDate>Mon, 21 Sep 2026 11:10:26 GMT</pubDate>
            <atom:updated>2026-09-21T11:15:33.917Z</atom:updated>
            <content:encoded><![CDATA[<p>Many cybersecurity detections are about the order of events over time, not just about a count of specific events:</p><ul><li>A brute-force attempt followed by a successful login can indicate an account takeover. Failed attempts without a later success are a different, usually lower-priority signal.</li><li>A beacon is a set of connections at a near-constant interval.</li><li>A hands-on-keyboard intrusion is a chain: an office document spawns a script, and the script reaches out to the network.</li><li>MFA was disabled and not enabled again during a given time window.</li></ul><p>In each case the signal is the sequence, and the order of the events defines the meaning.</p><p>Standard SQL isn’t a good fit for this. Counting events in a window is easy (a GROUP BY with a HAVING filter), but &quot;event A, then B, then C, in this order, for one user&quot; needs self-joins, chains of LAG()/LEAD(), or gaps-and-islands tricks. These queries are hard to write and to read a few months later.</p><p>MATCH_RECOGNIZE provides a direct way to describe such sequences. It&#39;s a row pattern matching clause from SQL:2016, available in some databases and streaming engines. And now it&#39;s <a href="https://learn.microsoft.com/en-us/azure/databricks/sql/language-manual/sql-ref-syntax-qry-select-match-recognize">available in Databricks SQL</a>.</p><p>It is a regular expression engine over time-ordered rows inside a partition. For cybersecurity, you partition the data by an entity (a user, a source IP, a host), order rows by time, and match a pattern against that ordered sequence.</p><p>This post and the next one cover detections over OCSF data. This post covers the basics of the usage and two single-entity patterns: beaconing and impossible travel.</p><blockquote><strong><em>Preview note.</em></strong><em> At the time of writing, </em><em>MATCH_RECOGNIZE is in Beta on Databricks Runtime 19.0 and above, and a workspace admin should enable it on the Previews page. Every query in this post and in part 2 was tested on a live Lakewatch workspace with the preview enabled. The field paths follow the Lakewatch OCSF &quot;gold&quot; schema, so adjust them if your data has a different layout.</em></blockquote><h3>The parts of a match</h3><p>We will start with the brute-force example because it shows every part of the MATCH_RECOGNIZE clause. The example from the pipe-syntax post finds users with 5 or more failed logins during the last hour:</p><pre>FROM authentication<br>|&gt; WHERE time &gt;= current_timestamp() - INTERVAL 1 HOUR<br>     AND status &lt;&gt; &#39;Success&#39;<br>|&gt; AGGREGATE COUNT(*) AS failures<br>   GROUP BY user.name AS user_name, src_endpoint.ip AS src_ip<br>|&gt; WHERE failures &gt;= 5</pre><p>This is a good triage signal, but it cannot say whether any of those attempts succeeded. The more interesting case is 5 or more failures followed by a success from the same user and source IP: the pattern of a guessed password. With MATCH_RECOGNIZE, that is one line in the PATTERN clause. Because PARTITION BY takes plain columns, first flatten the nested OCSF fields into columns (a small CTE, or a pipe SELECT) and filter to the last day, then match:</p><pre>WITH auth AS (<br>  SELECT time,<br>         user.name        AS user_name,<br>         src_endpoint.ip  AS src_ip,<br>         status<br>  FROM authentication<br>  WHERE time &gt;= current_timestamp() - INTERVAL 1 DAY<br>)<br>SELECT *<br>FROM auth<br>MATCH_RECOGNIZE (<br>  PARTITION BY user_name, src_ip<br>  ORDER BY time<br>  MEASURES FIRST(time)  AS first_fail,<br>           LAST(time)   AS success_time,<br>           COUNT(*)     AS attempts<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (fail{5,} success)<br>  DEFINE fail    AS status &lt;&gt; &#39;Success&#39;,<br>         success AS status =  &#39;Success&#39;<br>) AS m;</pre><p>Read it from top to bottom:</p><ul><li>PARTITION BY user_name, src_ip runs the match once per user-and-source-ip pair, the same as a GROUP BY.</li><li>ORDER BY time puts the rows of each partition in time order. The pattern-matching and navigation functions use this order.</li><li>MEASURES builds the output columns for each match, using FIRST() and LAST(), and aggregates over the matched rows.</li><li>ONE ROW PER MATCH returns a single summary row per match.</li><li>AFTER MATCH SKIP PAST LAST ROW tells the engine to continue after the last matched row, so matches do not overlap.</li><li>PATTERN (fail{5,} success) is the regular expression: 5 or more fail rows, then one success row, all consecutive within the partition.</li><li>DEFINE names the row variables and gives each a boolean rule. Here, a fail is any row whose status is not success, and success is the opposite. A variable with no DEFINE entry matches any row.</li></ul><p>The query that just produces counts, and this one looks similar, but they answer different questions. The first query counts failures and stops there. The second query needs failures and then success in that order, so it fires on the takeover pattern and stays quiet on only failures.</p><p>One thing the example leaves out is the time between events. Success can arrive at any point after failures, so a failed burst on Monday and then a success on Friday would still be counted as one match (if you have a long interval). Real detections bound the gap between steps, which you can implement by adding a PREV() check in the DEFINE:</p><pre>DEFINE fail    AS status &lt;&gt; &#39;Success&#39;,<br>       success AS status =  &#39;Success&#39;<br>                  AND (PREV(time) IS NULL<br>                       OR unix_timestamp(time) - unix_timestamp(PREV(time)) &lt;= 30 * 60)</pre><p>Now, success is matched only when it occurs within 30 minutes of the previous attempt. Fifteen, 30, 60, or 90 minutes are the typical time windows you see most often in real rules.</p><h3>The pattern language</h3><p>The PATTERN clause is a small regular-expression language over the row variables you define. Databricks supports the full set of operators:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/608/1*um8W0zag1uBTpAjgRp1Gew.png" /></figure><p>Inside DEFINE and MEASURES, you have navigation functions to look across rows: PREV() and NEXT() read the previous or next row in ORDER BY order, FIRST() and LAST() read the first or last row of a variable, CLASSIFIER() returns which variable matched a row, and MATCH_NUMBER() numbers the matches inside a partition. A variable-qualified reference, such as fail.src_ip reads a column from the rows that matched a specific variable. PREV() and NEXT() take an optional offset, so PREV(time, 2) reads two rows back, but you cannot nest them: Databricks rejects PREV(PREV(time)), so use the offset form instead.</p><h3>Beaconing: connections at a near-constant interval</h3><p>Command-and-control beacons call back on a schedule, so the gap between one connection and the next stays about the same. That regularity is a sequence property: each interval is close to the one before it. PREV() gives you the previous row, so you can compare the current gap to the last gap and match a run of steady intervals.</p><pre>WITH flows AS (<br>  SELECT time,<br>         src_endpoint.ip   AS src_ip,<br>         dst_endpoint.ip   AS dst_ip,<br>         dst_endpoint.port AS dst_port<br>  FROM network_activity<br>  WHERE connection_info.direction = &#39;Outbound&#39;<br>    AND time &gt;= current_timestamp() - INTERVAL 1 DAY<br>)<br>SELECT *<br>FROM flows<br>MATCH_RECOGNIZE (<br>  PARTITION BY src_ip, dst_ip, dst_port<br>  ORDER BY time<br>  MEASURES FIRST(time) AS first_beat,<br>           LAST(time)  AS last_beat,<br>           COUNT(*)    AS beats<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (start regular{4,})<br>  DEFINE regular AS abs(<br>           (unix_timestamp(time)       - unix_timestamp(PREV(time)))<br>         - (unix_timestamp(PREV(time)) - unix_timestamp(PREV(time, 2)))<br>       ) &lt;= 2<br>) AS m;</pre><p>start has no DEFINE rule, so it matches any starting row and anchors the run. regular holds when the current interval is within 2 seconds of the previous interval. PREV(time, 2) reads two rows back. regular{4,} requires at least 4 such steps in a row, so the match is a source-to-destination pair that kept a steady rhythm. Tune the tolerance and the count to your data, and add a filter on beats to drop short sessions (you also need to exclude some services like infrastructure monitoring, etc.).</p><h3>Impossible travel</h3><p>Impossible travel is a two-row pattern: a successful login in one place, followed by another login from the same user so far away that the implied travel speed is physically impossible. Because MATCH_RECOGNIZE orders the rows by time and PREV() reads the earlier one, the whole test fits in a single DEFINE.</p><pre>WITH logins AS (<br>  SELECT time,<br>         user.name              AS user_name,<br>         src_endpoint.ip        AS src_ip,<br>         dst_endpoint.location.lat  AS lat,<br>         dst_endpoint.location.long AS lon<br>  FROM authentication<br>  WHERE status = &#39;Success&#39;<br>    AND dst_endpoint.location.lat IS NOT NULL<br>    AND time &gt;= current_timestamp() - INTERVAL 7 DAY<br>)<br>SELECT *<br>FROM logins<br>MATCH_RECOGNIZE (<br>  PARTITION BY user_name<br>  ORDER BY time<br>  MEASURES FIRST(time)   AS from_time,<br>           LAST(time)    AS to_time,<br>           FIRST(src_ip) AS from_ip,<br>           LAST(src_ip)  AS to_ip<br>  ONE ROW PER MATCH<br>  AFTER MATCH SKIP PAST LAST ROW<br>  PATTERN (a jump)<br>  DEFINE jump AS<br>    (<br>      6371 * acos(<br>        cos(radians(PREV(lat))) * cos(radians(lat))<br>          * cos(radians(lon) - radians(PREV(lon)))<br>        + sin(radians(PREV(lat))) * sin(radians(lat))<br>      )<br>    )<br>    / (greatest(unix_timestamp(time) - unix_timestamp(PREV(time)), 1) / 3600.0)<br>    &gt; 900<br>) AS m;</pre><p>a matches any successful login, and jump matches the next one when the great-circle distance from the previous location (calculated with the spherical law of cosines, in kilometers) divided by the hours between the two logins is above 900 km/h. The greatest(..., 1) guards against a divide-by-zero when two events share a timestamp.</p><h3>Before you jump to use it</h3><p>There are a few limits on how you write these queries:</p><ul><li><strong>Non-overlapping matches.</strong> Databricks supports only one skip mode, AFTER MATCH SKIP PAST LAST ROW, so once a match finishes, the engine continues after its last row, and matches do not overlap. This works for most detections (find the pattern, report it, move on), but it means you cannot count every overlapping window.</li><li><strong>Greedy quantifiers only.</strong> There are no non-greedy quantifiers (A+?). When you need to skip filler rows between stages, pre-filter to the rows of interest first so the stages sit next to each other. We use this in the process-chain example in <a href="https://alexott.blogspot.com/2026/09/detecting-attack-sequences-in_0330322033.html">part 2</a>.</li><li><strong>PARTITION BY takes columns only.</strong> You cannot partition by an expression, so flatten nested OCSF fields into columns or apply functions first. That&#39;s why every example starts with a CTE.</li><li><strong>Anchors only on boundaries.</strong> ^ and $ are allowed only at the start or end of the top-level pattern, not in the middle.</li><li><strong>Watch for partition skew.</strong> Partitioning and ordering shuffle and sort the data, so a single source IP or user with a lot of data will result in one large partition to sort. This is the same problem as with window functions.</li></ul><h3>Conclusion</h3><p>These examples show where MATCH_RECOGNIZE is useful. A run of failures followed by a success, a steady beacon interval, or a two-login location change can each be expressed as a short pattern instead of a chain of self-joins or window functions. This can make detection or threat-hunting queries easier to read and maintain. For a two-row comparison, such as impossible travel, a standard LAG() query can still be sufficient. Row pattern matching becomes more useful as the number and variability of the stages increase.</p><p>We’ll continue with more examples in <a href="https://medium.com/@alexott_en/detecting-attack-sequences-in-databricks-sql-with-match-recognize-part-2-de2e19ae9aa7">Part 2</a>.</p><h3>References</h3><ul><li>Databricks docs: <a href="https://learn.microsoft.com/en-us/azure/databricks/sql/language-manual/sql-ref-syntax-qry-select-match-recognize">MATCH_RECOGNIZE clause</a> and the <a href="https://learn.microsoft.com/en-us/azure/databricks/sql/language-manual/sql-ref-syntax-qry-select-match-recognize-pattern">PATTERN sub-clause</a>.</li><li><a href="https://alexott.blogspot.com/2026/08/why-databricks-sql-pipe-syntax-is-great.html">Why Databricks SQL pipe syntax is great for cybersecurity work</a> — the counted brute-force example and the OCSF field mapping.</li><li><a href="https://docs.lakewatch.com/">Lakewatch documentation</a>.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=793822a743ad" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>