<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-05-20T19:53:12+00:00</updated><id>/feed.xml</id><title type="html">Cloud-DevOps Ninja</title><subtitle>Sharing my adventures as a Cloud DevOps ninja</subtitle><author><name>[&quot;cdo-ninja&quot;]</name></author><entry><title type="html">Parsing the CIS Benchmark PDF into Structured JSON: A Regex Adventure</title><link href="/posts/2026/05/20/CIS-Benchmark-PDF-to-JSON.html" rel="alternate" type="text/html" title="Parsing the CIS Benchmark PDF into Structured JSON: A Regex Adventure" /><published>2026-05-20T11:00:00+00:00</published><updated>2026-05-20T11:00:00+00:00</updated><id>/posts/2026/05/20/CIS-Benchmark-PDF-to-JSON</id><content type="html" xml:base="/posts/2026/05/20/CIS-Benchmark-PDF-to-JSON.html"><![CDATA[<h2 id="how-i-turned-a-300-page-security-benchmark-document-into-a-machine-readable-database-the-ai-agent-can-use--with-a-lot-of-help-from-claude-code">How I turned a 300-page security benchmark document into a machine-readable database the AI agent can use … with a lot of help from Claude Code</h2>

<hr />

<h2 id="why-this-problem-exists">Why this problem exists</h2>

<p>The CIS Windows 11 Benchmark v4.0 is a 300-page PDF. It is the canonical reference for Windows hardening — every major endpoint management platform, auditor, and security team uses it. But it exists as a document for humans, not a database for machines.</p>

<p>To build a <a href="https://www.cloud-devops.ninja/posts/2026/05/19/IntuneCISCompliantAgent.html">compliance agent</a> that could compare Intune configuration profiles against CIS recommendations, I needed the benchmark data in a structured format: benchmark ID, setting name, recommended value, severity, description, remediation steps, and the exact Intune Settings Catalog path to configure it. None of that exists as a public API or pre-built dataset. The only authoritative source is the PDF.</p>

<p>So with a lot of help from Claude Code I created <code class="language-plaintext highlighter-rouge">extract_benchmarks.py</code> — a one-off script that reads <code class="language-plaintext highlighter-rouge">CIS_Microsoft_Intune_for_Windows_11_Benchmark_v4.0.0.pdf</code> and produces <code class="language-plaintext highlighter-rouge">benchmarks.json</code>. a JSON formatted list of all the CIS Benchmark recommendations from the PDF file. What looked like a simple, two-hour task took considerably longer. Here is what I ran into.</p>

<hr />

<h2 id="the-approach-extract-split-parse">The approach: extract, split, parse</h2>

<p>The script follows a three-stage pipeline:</p>

<ol>
  <li><strong>Extract</strong> — read all PDF pages into a single string using <code class="language-plaintext highlighter-rouge">pypdf</code></li>
  <li><strong>Split</strong> — locate every benchmark entry in the text using an anchor regex and carve out per-benchmark blocks</li>
  <li><strong>Parse</strong> — pull structured fields out of each block and build the final JSON</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">pypdf</span> <span class="kn">import</span> <span class="n">PdfReader</span>

<span class="n">r</span> <span class="o">=</span> <span class="n">PdfReader</span><span class="p">(</span><span class="s">"CIS_Microsoft_Intune_for_Windows_11_Benchmark_v4.0.0.pdf"</span><span class="p">)</span>
<span class="n">full_text</span> <span class="o">=</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">page</span><span class="p">.</span><span class="n">extract_text</span><span class="p">()</span> <span class="ow">or</span> <span class="s">""</span> <span class="k">for</span> <span class="n">page</span> <span class="ow">in</span> <span class="n">r</span><span class="p">.</span><span class="n">pages</span><span class="p">)</span>
</code></pre></div></div>

<p>Simple enough in concept. The complications were entirely in stages 2 and 3.</p>

<hr />

<h2 id="challenge-1-the-table-of-contents-echoes-every-benchmark-title">Challenge 1: The table of contents echoes every benchmark title</h2>

<p>The PDF has a table of contents that spans roughly twenty pages. It lists every benchmark by section number and name. When you extract the full text of the document, those titles appear twice — once in the ToC, once in the actual benchmark section.</p>

<p>This matters because I needed to split the document into per-benchmark blocks. Claude Code helped me use the following anchor pattern:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ID_LEVEL_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"(\d+(?:\.\d+)+)\s+\((L1|L2|BL|NG)\)\s+Ensure\s+"</span><span class="p">)</span>
</code></pre></div></div>

<p>This matches lines like <code class="language-plaintext highlighter-rouge">1.1 (L1) Ensure 'Allow Cortana Above Lock Screen' is set to 'Disabled'</code>. The problem: the ToC contains exactly this pattern for every benchmark, so <code class="language-plaintext highlighter-rouge">finditer</code> returned double the expected matches — one from the ToC entry and one from the actual content.</p>

<p>The filter was simple but non-obvious: every real benchmark section contains the string <code class="language-plaintext highlighter-rouge">"Profile Applicability:"</code>, which marks the start of the structured body. ToC lines do not. So after splitting on the anchor, Claude Code suggested to discard any block that lacked this string:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="s">"Profile Applicability:"</span> <span class="ow">in</span> <span class="n">block</span><span class="p">:</span>
    <span class="n">blocks</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">bench_id</span><span class="p">,</span> <span class="n">level</span><span class="p">,</span> <span class="n">block</span><span class="p">))</span>
</code></pre></div></div>

<p>This cut the block count exactly in half and left only genuine benchmark entries.</p>

<hr />

<h2 id="challenge-2-page-headers-and-footers-bleed-into-the-text">Challenge 2: Page headers and footers bleed into the text</h2>

<p>PDF text extraction does not know about document structure — it knows about character positions on a page. Headers and footers land in the extracted text wherever they happen to fall in reading order, which is often mid-sentence or mid-field.</p>

<p>The CIS PDF has page numbers that appear in the extracted text as isolated lines like <code class="language-plaintext highlighter-rouge">\nPage 47\n</code> in the middle of a description paragraph. Once again Claude Code came to the rescue with a method to strip the page numbers before any further processing:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">clean</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s">"\nPage \d+\s*\n"</span><span class="p">,</span> <span class="s">" "</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s">"\s+"</span><span class="p">,</span> <span class="s">" "</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">text</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>
</code></pre></div></div>

<p>The second substitution collapses all remaining whitespace — tabs, multiple spaces, newlines — into a single space. This is destructive (it loses formatting), but since I was extracting fields into flat strings anyway, it was the right trade-off for this use-case.</p>

<hr />

<h2 id="challenge-3-extracting-setting-name-and-recommended-value-from-the-title">Challenge 3: Extracting setting name and recommended value from the title</h2>

<p>Each benchmark title follows a pattern like:</p>

<blockquote>
  <p>Ensure ‘Allow Cortana Above Lock Screen’ is set to ‘Disabled’</p>
</blockquote>

<p>The setting name is in single quotes, and the recommended value is in the second quoted phrase. But not every title follows this pattern. Some read:</p>

<blockquote>
  <p>Ensure ‘Windows Firewall: Domain: Firewall state’ is set to ‘On (recommended)’</p>
</blockquote>

<p>And others are more irregular:</p>

<blockquote>
  <p>Ensure ‘Minimum password age’ is set to ‘1 or more day(s)’</p>
</blockquote>

<p>Luckily Claude Code found an elegant way for the extractor to handles this in two passes — first try to match the quoted pattern, then fall back to the raw title:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">extract_setting_and_recommendation</span><span class="p">(</span><span class="n">title_block</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="n">title</span> <span class="o">=</span> <span class="n">clean</span><span class="p">(</span><span class="n">title_block</span><span class="p">)</span>
    <span class="n">title</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s">"\s*\((Automated|Manual)\)\s*$"</span><span class="p">,</span> <span class="s">""</span><span class="p">,</span> <span class="n">title</span><span class="p">)</span>
    <span class="n">setting_m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">"'(.+?)'"</span><span class="p">,</span> <span class="n">title</span><span class="p">)</span>
    <span class="n">setting</span> <span class="o">=</span> <span class="n">setting_m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="k">if</span> <span class="n">setting_m</span> <span class="k">else</span> <span class="n">clean</span><span class="p">(</span><span class="n">title</span><span class="p">)[:</span><span class="mi">80</span><span class="p">]</span>
    <span class="n">rec_m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">"is set to '(.+?)'"</span><span class="p">,</span> <span class="n">title</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">rec_m</span><span class="p">:</span>
        <span class="n">rec_m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">"is set to (.+?)$"</span><span class="p">,</span> <span class="n">title</span><span class="p">)</span>
    <span class="n">recommendation</span> <span class="o">=</span> <span class="n">rec_m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="n">strip</span><span class="p">().</span><span class="n">rstrip</span><span class="p">(</span><span class="s">"."</span><span class="p">)</span> <span class="k">if</span> <span class="n">rec_m</span> <span class="k">else</span> <span class="s">""</span>
    <span class="k">return</span> <span class="n">setting</span><span class="p">,</span> <span class="n">recommendation</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">(Automated)</code> / <code class="language-plaintext highlighter-rouge">(Manual)</code> suffix that CIS appends to some titles also needed stripping — it is metadata about the audit method, not part of the setting name.</p>

<hr />

<h2 id="challenge-4-multi-line-csp-paths-broken-by-pdf-layout">Challenge 4: Multi-line CSP paths broken by PDF layout</h2>

<p>The remediation section of each benchmark contains the Intune Settings Catalog navigation path. In the source document this looks clean:</p>

<blockquote>
  <p><strong>Settings Catalog path:</strong> Above Lock &gt; Allow Cortana Above Lock</p>
</blockquote>

<p>But after PDF text extraction, long paths are broken across lines wherever the PDF renderer wrapped them, with no consistent delimiter. A path like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Administrative Templates\MS Security Guide\Enable Structured Exception
Handling Overwrite Protection (SEHOP)
</code></pre></div></div>

<p>comes out as two lines that need to be joined. Worse, some paths continue across a page boundary, which means a stray page number sits in the middle.</p>

<p>Claude Code suggestion was to have the CSP extractor use a regex that captures everything between the <code class="language-plaintext highlighter-rouge">"Settings Catalog path to"</code> marker and the next known field boundary, then have it join the lines:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">extract_csp</span><span class="p">(</span><span class="n">remediation_text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span>
        <span class="sa">r</span><span class="s">"Settings Catalog path to .+?\.\s*\n((?:(?!Default Value:|References:|Audit:|Impact:|Rationale:|Profile).+\n?)+)"</span><span class="p">,</span>
        <span class="n">remediation_text</span><span class="p">,</span>
        <span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">if</span> <span class="n">m</span><span class="p">:</span>
        <span class="n">lines</span> <span class="o">=</span> <span class="p">[</span><span class="n">l</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">l</span> <span class="ow">in</span> <span class="n">m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">l</span><span class="p">.</span><span class="n">strip</span><span class="p">()]</span>
        <span class="n">path_lines</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="sa">r</span><span class="s">"Note:"</span><span class="p">,</span> <span class="n">line</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">):</span>
                <span class="k">break</span>
            <span class="n">path_lines</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
        <span class="k">return</span> <span class="s">" </span><span class="se">\\</span><span class="s"> "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path_lines</span><span class="p">).</span><span class="n">rstrip</span><span class="p">(</span><span class="s">"."</span><span class="p">)</span>
    <span class="k">return</span> <span class="s">""</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">" \\ ".join(path_lines)</code> reconstructs the backslash-separated hierarchy from whatever the PDF broke into separate lines. The <code class="language-plaintext highlighter-rouge">Note:</code> stop condition prevents footnotes that immediately follow the path from being absorbed into it.</p>

<hr />

<h2 id="challenge-5-not-all-benchmarks-can-be-configured-through-the-ui">Challenge 5: Not all benchmarks can be configured through the UI</h2>

<p>Some CIS controls apply to settings that Intune cannot configure through the Settings Catalog or Administrative Templates. They require either a direct registry OMA-URI or a PowerShell script. The PDF says so explicitly in the remediation text: <em>“This setting is not possible through Settings Catalog”</em>.</p>

<p>For these, Claude Code added the following code so the remediation steps builder detects the signal and changes the output entirely:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">is_powershell_only</span> <span class="o">=</span> <span class="s">"not possible through Settings Catalog"</span> <span class="ow">in</span> <span class="n">text</span> <span class="ow">or</span> <span class="p">(</span>
    <span class="s">"PowerShell"</span> <span class="ow">in</span> <span class="n">text</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">is_settings_catalog</span>
<span class="p">)</span>

<span class="k">if</span> <span class="n">is_powershell_only</span><span class="p">:</span>
    <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span>
        <span class="s">"Remediation not possible via Settings Catalog or OMA-URI; "</span>
        <span class="s">"deploy via Intune Scripts or Remediations blade"</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>Rather than generating a Settings Catalog navigation path that does not exist, the tool tells the agent (and the user) the honest truth: this one needs a script.</p>

<hr />

<h2 id="challenge-6-generating-useful-remediation-steps-not-raw-pdf-text">Challenge 6: Generating useful remediation steps, not raw PDF text</h2>

<p>The raw PDF remediation sections are verbose. They contain audit procedures, default value explanations, references to Group Policy paths, and sometimes two or three paragraphs of context. None of that is useful to someone who just wants to know where to click in Intune.</p>

<p>Here Claude Code suggested code so the <code class="language-plaintext highlighter-rouge">build_remediation_steps</code> function synthesizes a four-to-five step list tailored to the profile type:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">build_remediation_steps</span><span class="p">(</span><span class="n">remediation_text</span><span class="p">,</span> <span class="n">csp</span><span class="p">,</span> <span class="n">setting</span><span class="p">,</span> <span class="n">recommendation</span><span class="p">):</span>
    <span class="n">steps</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">is_settings_catalog</span> <span class="o">=</span> <span class="s">"Settings Catalog"</span> <span class="ow">in</span> <span class="n">text</span>
    <span class="n">is_admin_templates</span> <span class="o">=</span> <span class="s">"Administrative Templates"</span> <span class="ow">in</span> <span class="p">(</span><span class="n">csp</span> <span class="ow">or</span> <span class="s">""</span><span class="p">)</span>
    <span class="n">is_endpoint_security</span> <span class="o">=</span> <span class="nb">any</span><span class="p">(</span>
        <span class="n">kw</span> <span class="ow">in</span> <span class="p">(</span><span class="n">csp</span> <span class="ow">or</span> <span class="s">""</span><span class="p">).</span><span class="n">lower</span><span class="p">()</span>
        <span class="k">for</span> <span class="n">kw</span> <span class="ow">in</span> <span class="p">[</span><span class="s">"firewall"</span><span class="p">,</span> <span class="s">"defender"</span><span class="p">,</span> <span class="s">"antivirus"</span><span class="p">,</span> <span class="s">"exploit"</span><span class="p">,</span> <span class="s">"bitlocker"</span><span class="p">]</span>
    <span class="p">)</span>

    <span class="c1"># Step 1 — where to navigate in Intune
</span>    <span class="k">if</span> <span class="n">is_admin_templates</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"In Intune: Devices &gt; Configuration profiles &gt; Create &gt; "</span>
                     <span class="s">"Windows 10 and later &gt; Administrative Templates"</span><span class="p">)</span>
    <span class="k">elif</span> <span class="n">is_settings_catalog</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"In Intune: Devices &gt; Configuration profiles &gt; Create &gt; "</span>
                     <span class="s">"Windows 10 and later &gt; Settings Catalog"</span><span class="p">)</span>
    <span class="k">elif</span> <span class="n">is_endpoint_security</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"In Intune: Endpoint Security &gt; select the relevant policy type"</span><span class="p">)</span>

    <span class="c1"># Step 2 — what to configure
</span>    <span class="k">if</span> <span class="n">csp</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"Navigate to and configure: </span><span class="si">{</span><span class="n">csp</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="c1"># Step 3 — value to set
</span>    <span class="k">if</span> <span class="n">recommendation</span> <span class="ow">and</span> <span class="n">csp</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"Set the value to: </span><span class="si">{</span><span class="n">recommendation</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="c1"># Step 4 — any PDF notes
</span>    <span class="n">note_m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">"Note:\s*(.+?)(?:Default Value:|References:|$)"</span><span class="p">,</span>
                       <span class="n">remediation_text</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span> <span class="o">|</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">note_m</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"Note: </span><span class="si">{</span><span class="n">clean</span><span class="p">(</span><span class="n">note_m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">))[</span><span class="si">:</span><span class="mi">200</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="c1"># Step 5 — assign
</span>    <span class="k">if</span> <span class="n">steps</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">is_powershell_only</span><span class="p">:</span>
        <span class="n">steps</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"Assign the policy to the target Windows 11 device groups"</span><span class="p">)</span>
</code></pre></div></div>

<p>The distinction between Settings Catalog, Administrative Templates, and Endpoint Security is important for the agent: these are three different places in the Intune portal, and a user following the wrong navigation path will not find the setting.</p>

<hr />

<h2 id="challenge-7-mapping-section-numbers-to-category-names">Challenge 7: Mapping section numbers to category names</h2>

<p>The JSON output is keyed by category name, not by section number. <code class="language-plaintext highlighter-rouge">"Above Lock"</code>, <code class="language-plaintext highlighter-rouge">"Administrative Templates"</code>, <code class="language-plaintext highlighter-rouge">"Credential Guard"</code>, and so on. The mapping from section number (<code class="language-plaintext highlighter-rouge">1</code>, <code class="language-plaintext highlighter-rouge">4</code>, <code class="language-plaintext highlighter-rouge">9</code>…) to human-readable name came from the ToC.</p>

<p>Parsing the ToC reliably was its own small problem. The table of contents lines look like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1   Above Lock .......... 42
4   Administrative Templates .......... 87
</code></pre></div></div>

<p>But the dots and page numbers vary in format. Fortunately for me Claude Code is also skilled in regex and produced the regex that captures them:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">toc_re</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span>
    <span class="sa">r</span><span class="s">"^[ \t]*(\d{1,3})[ \t]+([A-Za-z][A-Za-z0-9 /\(\)\-]+?)[ \t]*(?:\.{3,}|[ \t]+\d+[ \t]*$)"</span><span class="p">,</span>
    <span class="n">re</span><span class="p">.</span><span class="n">MULTILINE</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This matches a leading number, then a name that starts with a letter and contains alphanumeric characters and common punctuation, followed by either a dotted leader (<code class="language-plaintext highlighter-rouge">......</code>) or a plain number at the end of the line. The <code class="language-plaintext highlighter-rouge">"ensure" not in name.lower()</code> guard drops individual benchmark titles that happen to match the pattern.</p>

<hr />

<h2 id="the-result">The result</h2>

<p>Running the script on the 300-page PDF takes about 30 seconds and produces a structured JSON file grouped by category:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reading PDF...
  323 pages, 1,247,831 chars
  Mapped 18 top-level sections

  Found 246 benchmark blocks

Parsed 246 benchmarks across 18 categories
  (14 PowerShell-only / no CSP path)

Category breakdown:
  Above Lock: 1
  Administrative Templates: 47
  Credential Guard: 4
  Firewall: 24
  ...

Wrote benchmarks.json (187 KB)
</code></pre></div></div>

<p>246 benchmarks, 18 categories, 187 KB. Every entry has a structured id, setting name, recommendation, severity, description, CSP path, and a list of concrete Intune navigation steps.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Administrative Templates"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"4.1.3.1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"setting"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Prevent enabling lock screen camera"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"recommendation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Enabled"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"High"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Disables the lock screen camera toggle switch in PC Settings and prevents a camera from being invoked on the lock screen."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"csp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Administrative Templates</span><span class="se">\\</span><span class="s2">Control Panel</span><span class="se">\\</span><span class="s2">Personalization</span><span class="se">\\</span><span class="s2">Prevent enabling lock </span><span class="se">\\</span><span class="s2"> screen camera"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"remediation"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"In Intune: Devices &gt; Configuration profiles &gt; Create &gt; Windows 10 and later &gt; Administrative Templates"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"Navigate to and configure: Administrative Templates</span><span class="se">\\</span><span class="s2">Control Panel</span><span class="se">\\</span><span class="s2">Personalization</span><span class="se">\\</span><span class="s2">Prevent enabling lock </span><span class="se">\\</span><span class="s2"> screen camera"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"Set the value to: Enabled"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"Assign the policy to the target Windows 11 device groups"</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="what-the-agent-does-with-it">What the agent does with it</h2>

<p>The benchmark database is loaded once at container startup and held in memory:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CISBenchmarkDatabase</span><span class="p">:</span>
    <span class="n">BENCHMARKS</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">_BENCHMARKS_FILE</span><span class="p">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">))</span>
</code></pre></div></div>

<p>The agent has three tools for querying it:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">get_cis_benchmarks(category)</code> — all controls in a category</li>
  <li><code class="language-plaintext highlighter-rouge">search_cis_benchmarks(query)</code> — keyword search across all setting names</li>
  <li><code class="language-plaintext highlighter-rouge">assess_compliance_status(benchmark_id)</code> — full detail and remediation for one control</li>
</ul>

<p>No network call, no external API. The LLM gets structured, pre-processed data it can reason over directly — rather than being asked to interpret raw PDF prose in context.</p>

<hr />

<h2 id="lessons-learned">Lessons learned</h2>

<p><strong>Claude Code’s reasoning rocks, it sure helped this human-in-the-loop understand and validate the code.</strong> I really enjoy my first Claude Code experience as it not just speed up my code production, but gave me a great learning experience by following its reasoning to not just understand the code but also validate the code and the JSON output. So I encourage you to not just copy and paste the code, but ask your prefered Model/Agent/AI to create your own version of the script (and have fun watching its reasoning and iterations as it tackles the challenges it faces)</p>

<p><strong>PDFs are not documents, they are drawing instructions.</strong> Text extractors reconstruct reading order from character positions, which means anything the PDF author relied on visually (line breaks, columns, indentation) may not survive extraction intact. Budget time for this.</p>

<p><strong>Anchor-based splitting beats page-by-page processing.</strong> Trying to process the PDF page by page would have been fragile — benchmark entries frequently span pages. Finding a reliable anchor that marks the start of each benchmark and slicing the full text string was simpler and more robust.</p>

<p><strong>Generate output for the consumer, not for the document.</strong> The raw PDF remediation text is written for a human reading a PDF. The JSON output should be written for an LLM reasoning about Intune. Those are different audiences with different needs, and the gap between them is where most of the parsing logic lives.</p>

<p><strong>One-off scripts deserve real engineering.</strong> This script only runs once per benchmark version. But because it was the foundation the entire agent was built on, errors in it would silently produce wrong compliance answers. Investing in the regex quality, the fallbacks, and the edge case handling was worth it.</p>]]></content><author><name>cdo-ninja</name></author><category term="posts" /><category term="blog" /><summary type="html"><![CDATA[How I turned a 300-page security benchmark document into a machine-readable database the AI agent can use … with a lot of help from Claude Code]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/posts/PDF2JSONConverter.png" /><media:content medium="image" url="/assets/images/posts/PDF2JSONConverter.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From Blog Post to Working Agent: Building an Intune CIS Compliance Checker with Azure AI Foundry</title><link href="/posts/2026/05/19/IntuneCISCompliantAgent.html" rel="alternate" type="text/html" title="From Blog Post to Working Agent: Building an Intune CIS Compliance Checker with Azure AI Foundry" /><published>2026-05-19T11:00:00+00:00</published><updated>2026-05-19T11:00:00+00:00</updated><id>/posts/2026/05/19/IntuneCISCompliantAgent</id><content type="html" xml:base="/posts/2026/05/19/IntuneCISCompliantAgent.html"><![CDATA[<h2 id="how-jannik-reinhards-open-source-intune-agent-became-the-foundation-for-an-ai-powered-cis-benchmark-compliance-tool">How Jannik Reinhard’s open-source Intune agent became the foundation for an AI-powered CIS benchmark compliance tool</h2>

<hr />

<h2 id="the-spark">The spark</h2>

<p>A few months ago I came across <a href="https://jannikreinhard.com/2026/01/02/building-your-own-intune-agent-with-microsoft-foundry/">Jannik Reinhard’s blog post</a> on building your own Intune agent with Microsoft Foundry. Jannik is well known in the Intune community for his tooling and automation work, and his post did something rare: it showed me in clear steps how to create a working, deployable example of an Intune agent in Microsoft Foundry that talks to a real Microsoft Graph endpoint — not a toy demo, but something I could actually plug into my own tenant.</p>

<p>Reading it, I felt confident that I could extend his framework with my specific use-case: an Agent that could check the CIS benchmark compliance of my Intune policies. The agent Jannik built answers general Intune questions. But what if it could also compare what it finds against the <strong>CIS Windows 11 Benchmark</strong>? Instead of just telling you <em>what</em> your configuration profiles contain, it could tell you <em>whether those settings are compliant</em> with a recognized security standard — and what to do if they are not.</p>

<p>That idea became this project.</p>

<hr />

<h2 id="what-i-built">What I built</h2>

<p>The <strong>Intune CIS Compliance Agent</strong> is a hosted AI agent that:</p>

<ol>
  <li>Connects to your Intune tenant via Microsoft Graph</li>
  <li>Reads your device configuration profiles, compliance policies, and device inventory</li>
  <li>Cross-references that data against the CIS Windows 11 Benchmark v4.0</li>
  <li>Answers plain-English questions about compliance gaps and remediation steps</li>
</ol>

<p>You run it inside Azure AI Foundry as a <code class="language-plaintext highlighter-rouge">kind: hosted</code> agent, and interact with it through the Foundry playground — no custom UI needed.</p>

<p>Example questions it can answer:</p>

<ul>
  <li><em>“Is CP-Win-SC-Baseline compliant with CIS benchmark recommendations?”</em></li>
  <li><em>“Which Windows devices are non-compliant and what policies are they missing?”</em></li>
  <li><em>“Search CIS benchmarks for firewall settings”</em></li>
  <li><em>“What are the remediation steps for benchmark 5.1.1?”</em></li>
</ul>

<hr />

<h2 id="standing-on-janniks-shoulders">Standing on Jannik’s shoulders</h2>

<p>I started from Jannik’s <a href="https://github.com/JayRHa/IntuneAgent">IntuneAgent repository</a>. The core architecture he established — a Microsoft Agent Framework agent, Microsoft Graph helpers, <code class="language-plaintext highlighter-rouge">@ai_function</code>-decorated tools, and a Dockerfile targeting the Foundry <code class="language-plaintext highlighter-rouge">responses</code> protocol — was exactly the right foundation. I kept that structure and layered in three things:</p>

<p><strong>1. CIS benchmark data</strong>
Unfortunately you can only download PDF formatted CIS Benchmark files, which present some additional challenges and introduce higher token consumptions for most agents.</p>

<p>Luckily I got some help and magic from Claude Code that helped me to extract the CIS Windows 11 Benchmark v4.0 controls into a <code class="language-plaintext highlighter-rouge">benchmarks.json</code> file and built a <code class="language-plaintext highlighter-rouge">CISBenchmarkDatabase</code> class that supports category lookups, keyword search, and direct ID lookups. The benchmark data lives locally in the container — no external API call required to check a control.</p>

<p><em>Note: I’ll share more on my CIS Benchmark PDF to JSON convertion in the next post.</em></p>

<p><strong>2. Settings-level profile inspection</strong>
Intune stores configuration profiles across three separate Graph API endpoints: Settings Catalog (<code class="language-plaintext highlighter-rouge">/configurationPolicies</code>), Legacy templates (<code class="language-plaintext highlighter-rouge">/deviceConfigurations</code>), and Administrative Templates (<code class="language-plaintext highlighter-rouge">/groupPolicyConfigurations</code>).</p>

<p>With help from Claude Code I extended Jannik’s original <code class="language-plaintext highlighter-rouge">get_device_configuration_settings</code> tool to fetch the actual per-setting values from each profile and normalizes them into a format the LLM can compare against benchmark expectations, ensuring it included Settings Catalog, Legacy Templates and Administrative Templates settings.</p>

<p>The tool accepts an optional <code class="language-plaintext highlighter-rouge">profile_name</code> parameter. When provided, it passes <code class="language-plaintext highlighter-rouge">$filter=name eq '...'</code> (Settings Catalog) or <code class="language-plaintext highlighter-rouge">$filter=displayName eq '...'</code> (Legacy) directly to the Graph API — so only the matching profile is fetched, and settings details are retrieved only for that one profile. This reduces a query about a specific profile from potentially 15+ sequential Graph API calls down to 2, and cuts the LLM context from a full tenant dump to a single profile. When <code class="language-plaintext highlighter-rouge">profile_name</code> is omitted, the tool falls back to fetching everything — useful for broad compliance surveys.</p>

<p><strong>3. Compliance assessment tools</strong>
And with the help from Claude Code I added three new agent tools — <code class="language-plaintext highlighter-rouge">get_cis_benchmarks</code>, <code class="language-plaintext highlighter-rouge">search_cis_benchmarks</code>, and <code class="language-plaintext highlighter-rouge">assess_compliance_status</code> — to give the agent everything it needs to answer compliance questions: what the benchmark recommends, what severity applies, and what the exact remediation steps are.</p>

<hr />

<h2 id="the-harder-part-making-it-actually-work-in-foundry">The harder part: making it actually work in Foundry</h2>

<p>The agent logic was relatively straightforward. The interesting engineering was in making the container work correctly as a Foundry hosted agent. Foundry’s <code class="language-plaintext highlighter-rouge">kind: hosted</code> protocol sits between the playground UI and your container, and there are some gaps between what Foundry sends and what the Agent Framework’s development server expects.</p>

<p>I worked through five issues, each one revealed only after the previous was fixed.</p>

<h3 id="issue-1--the-container-exited-immediately">Issue 1 — The container exited immediately</h3>

<p>Running locally, <code class="language-plaintext highlighter-rouge">main.py</code> dropped into an interactive <code class="language-plaintext highlighter-rouge">input()</code> loop and worked as expected.
In a container, stdin is not a terminal — the first <code class="language-plaintext highlighter-rouge">input()</code> call immediately hits EOF and the process exits.
Foundry sees the container crash within seconds of starting. It took me a little while to understand the log messages and work with Claude Code on a fix.</p>

<p><strong>Claude Code Fix:</strong> Check <code class="language-plaintext highlighter-rouge">sys.stdin.isatty()</code> at startup. If there is no terminal, skip the interactive loop entirely and start an HTTP server. If there is a terminal, run the interactive loop as normal.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="ow">not</span> <span class="n">sys</span><span class="p">.</span><span class="n">stdin</span><span class="p">.</span><span class="n">isatty</span><span class="p">():</span>
    <span class="c1"># start HTTP server
</span><span class="k">else</span><span class="p">:</span>
    <span class="c1"># interactive loop
</span></code></pre></div></div>

<h3 id="issue-2--health-probes-returned-404">Issue 2 — Health probes returned 404</h3>

<p>Foundry sends <code class="language-plaintext highlighter-rouge">GET /readiness</code> probes before routing any traffic to the container. If those probes fail, Foundry kills the container before a single user request arrives. The Agent Framework’s <code class="language-plaintext highlighter-rouge">serve()</code> helper exposes <code class="language-plaintext highlighter-rouge">/health</code> — but not <code class="language-plaintext highlighter-rouge">/readiness</code> or <code class="language-plaintext highlighter-rouge">/liveness</code>, which Foundry requires.</p>

<p><strong>Claude Code Fix:</strong> Use <code class="language-plaintext highlighter-rouge">DevServer</code> directly instead of <code class="language-plaintext highlighter-rouge">serve()</code>, get its underlying FastAPI app, and register the missing routes:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">devserver</span> <span class="o">=</span> <span class="n">DevServer</span><span class="p">(</span><span class="n">port</span><span class="o">=</span><span class="mi">8088</span><span class="p">,</span> <span class="n">host</span><span class="o">=</span><span class="s">"0.0.0.0"</span><span class="p">,</span> <span class="n">ui_enabled</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
<span class="n">devserver</span><span class="p">.</span><span class="n">set_pending_entities</span><span class="p">([</span><span class="n">agent</span><span class="p">])</span>
<span class="n">app</span> <span class="o">=</span> <span class="n">devserver</span><span class="p">.</span><span class="n">get_app</span><span class="p">()</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/readiness"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">readiness</span><span class="p">():</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"ready"</span><span class="p">}</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/liveness"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">liveness</span><span class="p">():</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"alive"</span><span class="p">}</span>
</code></pre></div></div>

<h3 id="issue-3--the-responses-endpoint-returned-404">Issue 3 — The responses endpoint returned 404</h3>

<p>Foundry calls <code class="language-plaintext highlighter-rouge">POST /responses</code>. DevServer listens on <code class="language-plaintext highlighter-rouge">POST /v1/responses</code>. Foundry does not let you configure the path it calls.</p>

<p><strong>Claude Code Fix:</strong> A pure ASGI middleware class that rewrites the path in the request scope before forwarding:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">scope</span><span class="p">[</span><span class="s">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"http"</span> <span class="ow">and</span> <span class="n">scope</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"path"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"/responses"</span><span class="p">:</span>
    <span class="n">scope</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">scope</span><span class="p">)</span>
    <span class="n">scope</span><span class="p">[</span><span class="s">"path"</span><span class="p">]</span> <span class="o">=</span> <span class="s">"/v1/responses"</span>
    <span class="n">scope</span><span class="p">[</span><span class="s">"raw_path"</span><span class="p">]</span> <span class="o">=</span> <span class="sa">b</span><span class="s">"/v1/responses"</span>
</code></pre></div></div>

<p>Using a pure ASGI class (rather than FastAPI’s <code class="language-plaintext highlighter-rouge">BaseHTTPMiddleware</code>) matters here: <code class="language-plaintext highlighter-rouge">BaseHTTPMiddleware</code> buffers the entire response before forwarding it to the client, which silently breaks streaming. A pure ASGI class passes the <code class="language-plaintext highlighter-rouge">send</code> callable through unchanged, so SSE frames flow out in real time.</p>

<h3 id="issue-4--the-request-returned-400-missing-entity_id">Issue 4 — The request returned 400: Missing entity_id</h3>

<p>DevServer routes requests to a specific registered agent using <code class="language-plaintext highlighter-rouge">metadata.entity_id</code> in the request body. Foundry’s Responses protocol does not send this field.</p>

<p>The entity_id is generated with a random UUID suffix at startup — something like <code class="language-plaintext highlighter-rouge">agent_in_memory_intunecomplianceagent_14fd26e737bb47a29d00501a2576f13e</code> — so it cannot be hardcoded.</p>

<p><strong>Claude Code Fix:</strong> In the middleware, resolve the entity_id at request time by calling the executor’s entity discovery, then inject it into the buffered request body before forwarding:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">executor</span> <span class="o">=</span> <span class="k">await</span> <span class="n">devserver</span><span class="p">.</span><span class="n">_ensure_executor</span><span class="p">()</span>
<span class="n">entities</span> <span class="o">=</span> <span class="n">executor</span><span class="p">.</span><span class="n">entity_discovery</span><span class="p">.</span><span class="n">list_entities</span><span class="p">()</span>
<span class="n">entity_id</span> <span class="o">=</span> <span class="n">entities</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nb">id</span>  <span class="c1"># cached after first call
</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">raw_body</span><span class="p">)</span>
<span class="n">data</span><span class="p">[</span><span class="s">"metadata"</span><span class="p">].</span><span class="n">setdefault</span><span class="p">(</span><span class="s">"entity_id"</span><span class="p">,</span> <span class="n">entity_id</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">_ensure_executor()</code> initializes once and caches the result, so the per-request overhead is negligible.</p>

<h3 id="issue-5--the-stream-was-cancelled-one-second-into-execution">Issue 5 — The stream was cancelled one second into execution</h3>

<p>This one was subtle. The request returned <code class="language-plaintext highlighter-rouge">200 OK</code>, the agent started executing, the LLM credential was obtained — and then <code class="language-plaintext highlighter-rouge">CancelledError</code>. The log showed:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ClientSecretCredential.get_token_info succeeded
[CANCELLATION] Execution cancelled via CancelledError
ERROR: ASGI callable returned without completing response.
</code></pre></div></div>

<p>The bug was in <code class="language-plaintext highlighter-rouge">_patched_receive</code>, the callable I substituted for the original <code class="language-plaintext highlighter-rouge">receive</code> so DevServer would read the patched body instead of the original. After the body was consumed, subsequent calls returned <code class="language-plaintext highlighter-rouge">{"type": "http.disconnect"}</code> immediately.</p>

<p>In ASGI, the server calls <code class="language-plaintext highlighter-rouge">receive()</code> a second time during streaming to detect client disconnection. My function answered “yes, the client disconnected” before they actually had — causing DevServer to cancel the in-flight LLM call.</p>

<p><strong>Claude Code Fix:</strong> Forward subsequent calls to the real <code class="language-plaintext highlighter-rouge">receive</code> rather than faking a disconnect:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">_patched_receive</span><span class="p">():</span>
    <span class="k">nonlocal</span> <span class="n">consumed</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">consumed</span><span class="p">:</span>
        <span class="n">consumed</span> <span class="o">=</span> <span class="bp">True</span>
        <span class="k">return</span> <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"http.request"</span><span class="p">,</span> <span class="s">"body"</span><span class="p">:</span> <span class="n">patched_body</span><span class="p">,</span> <span class="s">"more_body"</span><span class="p">:</span> <span class="bp">False</span><span class="p">}</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">receive</span><span class="p">()</span>  <span class="c1"># forward to real receive for disconnect detection
</span></code></pre></div></div>

<p>After this fix, the agent ran to completion and the Foundry playground showed a full response.</p>

<hr />

<h2 id="the-result">The result</h2>

<p>The full fix sequence — five issues, five deployments — took a morning. Each issue was only visible after fixing the previous one, which is what made it interesting rather than frustrating. The container logs were clear at every step.</p>

<p>The end result is an agent that:</p>

<ul>
  <li>Starts cleanly in a Foundry container (no TTY crash)</li>
  <li>Passes health probes immediately on startup</li>
  <li>Accepts requests from the Foundry playground</li>
  <li>Routes them correctly to the in-memory agent</li>
  <li>Streams the response back in real time</li>
  <li>Keeps working across container restarts (entity_id looked up fresh each time)</li>
</ul>

<div class="embed-container">
    <iframe src="https://www.youtube.com/embed/6af8Wou7sDY" width="700" height="480" frameborder="0" allowfullscreen="">
    </iframe>
</div>
<p>demo host: <a href="/members/esther-barthel" target="_blank">Esther Barthel</a></p>

<hr />

<h2 id="what-i-learned-with-a-lot-of-help-from-claude-code">What I learned (with a lot of help from Claude Code)</h2>

<p><strong>The Microsoft Agent Framework is genuinely useful.</strong> The <code class="language-plaintext highlighter-rouge">@ai_function</code> decorator handles JSON schema generation, parameter validation, and tool registration automatically. Writing a tool is just writing a Python function with type hints and a docstring. The framework takes care of the rest.</p>

<p><strong>Foundry’s <code class="language-plaintext highlighter-rouge">kind: hosted</code> protocol is powerful but under-documented.</strong> The Responses protocol v1 is the right approach for bringing your own agent infrastructure — but the exact contract between Foundry and your container (which paths it calls, which metadata it sends, which fields it requires) is not fully spelled out in the public docs. Fortunately for me Claude Code was able to read the DevServer source code and filled in the gaps.</p>

<p><strong>Pure ASGI middleware is the right tool for this job.</strong> Any time you need to intercept streaming HTTP — whether to rewrite a path, inject a header, or modify a request body — reach for a pure ASGI class. <code class="language-plaintext highlighter-rouge">BaseHTTPMiddleware</code> looks simpler but will silently break anything that streams.</p>

<p><strong>The CIS benchmark is a solid target.</strong> The CIS Windows 11 Benchmark v4.0 has 300+ controls covering Security Options, User Rights, Firewall, Credential Guard, and more. Having those controls as structured data that an LLM can query gives the agent a credible, externally validated baseline to reason from — much better than asking the model to rely on training-time knowledge of what “secure” means. So a big thanks to Claude Code for offering a manageable solution to translate the PDF content to a structured JSON format.</p>

<hr />

<h2 id="whats-next">What’s next</h2>

<p>I’m fully aware that right now, my additions make a great demo, but is nowhere near production ready.<br />
Here are a few things I want to add:</p>

<ul>
  <li><strong>Remediation output formatting</strong> — the agent’s compliance answers are accurate but verbose. A structured output format (compliant / non-compliant / not-configured per control, with a summary table) would make the results easier to act on.</li>
  <li><strong>Multi-tenant support</strong> — the current design assumes a single tenant. With minor changes it could accept a tenant parameter per query and rotate credentials accordingly.</li>
  <li><strong>Cleanup of the required environment variables</strong>— Initially I struggled with understanding the different ways the required environment variables were being set (local versus Foundry), so I’m pretty sure there are still double entries to ‘just get this code working’.</li>
</ul>

<hr />

<h2 id="getting-started">Getting started</h2>

<p>The full code can be found in <a href="https://github.com/cloud-devops-ninja/Foundry-Intune-CIS-Compliant-Agent">this</a> repository.<br />
You will need:</p>

<ul>
  <li>An Azure subscription with AI Foundry and a model deployment</li>
  <li>An Azure AD app registration with <code class="language-plaintext highlighter-rouge">DeviceManagementManagedDevices.Read.All</code> and <code class="language-plaintext highlighter-rouge">DeviceManagementConfiguration.Read.All</code> Graph permissions</li>
  <li>Docker (or the AI Foundry VS Code extension) to build and push the container</li>
</ul>

<h4 id="steps-to-run-the-agent-locally">Steps to run the agent locally</h4>

<ol>
  <li>Clone the repo</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">git clone https://github.com/cloud-devops-ninja/Foundry-Intune-CIS-Compliant-Agent.git</code><br />
<code class="language-plaintext highlighter-rouge">cd Foundry-Intune-CIS-Compliant-Agent</code></p>

<ol>
  <li>Copy <code class="language-plaintext highlighter-rouge">.env.example</code> to <code class="language-plaintext highlighter-rouge">.env</code> and fill in your credentials<br />
(make sure .env is part of .gitignore and .dockerignore)</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">copy .env.example .env</code></p>

<ol>
  <li>Create a virtual environment</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">python -m venv venv</code></p>

<ol>
  <li>Activate the venv</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">venv\Scripts\activate</code></p>

<ol>
  <li>Upgrade pip and install dependencies</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">python -m pip install --upgrade pip</code><br />
<code class="language-plaintext highlighter-rouge">pip install -r requirements.txt</code></p>

<ol>
  <li>Run the Agent to try it locally.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">python main.py</code></p>

<h4 id="steps-to-deploy-the-agent-to-foundry-using-the-foundry-toolkit-for-vs-code-extension">Steps to deploy the agent to Foundry (using the Foundry Toolkit for VS Code extension)</h4>

<ol>
  <li>Enter <code class="language-plaintext highlighter-rouge">&lt;Ctrl&gt;+&lt;Shift&gt;+P</code></li>
  <li>Type or select <code class="language-plaintext highlighter-rouge">Microsoft Foundry: Deploy Hosted Agent</code> to Deploy the Agent to Foundry</li>
  <li>Select Default ACR to have Foundry create an Azure Container Registry for the docker image</li>
  <li>Check the progress of the deployment in the Output panel
    <ol>
      <li>Setting up container registry…</li>
      <li>Building and pushing container image…</li>
      <li>Creating hosted agent…</li>
    </ol>
  </li>
  <li>When the deployment is finished, the Agent Playground panel will automatically open with the Hosted Agent</li>
  <li>Test your agent</li>
</ol>

<hr />

<p><em>Once more a big thank you to Jannik Reinhard for the original IntuneAgent concept and codebase — this project would not exist without that starting point.</em></p>]]></content><author><name>cdo-ninja</name></author><category term="posts" /><category term="blog" /><summary type="html"><![CDATA[How Jannik Reinhard’s open-source Intune agent became the foundation for an AI-powered CIS benchmark compliance tool]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/posts/microsoft-foundry.png" /><media:content medium="image" url="/assets/images/posts/microsoft-foundry.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Workplace Ninja Summit 2023</title><link href="/events/2023/09/27/workplaceninjasummit-baden-2023.html" rel="alternate" type="text/html" title="Workplace Ninja Summit 2023" /><published>2023-09-27T00:00:00+00:00</published><updated>2023-09-27T00:00:00+00:00</updated><id>/events/2023/09/27/workplaceninjasummit-baden-2023</id><content type="html" xml:base="/events/2023/09/27/workplaceninjasummit-baden-2023.html"><![CDATA[<p>The goal of the Workplace Ninja Summit 2023 is to bring the crowd of workplace management and security ninjas together to share their knowledge, learn together. This covers topics around management of endpoints with configuration manager and Intune, as well virtual desktops and the complete security stack of Microsoft.</p>

<p>Covered Topics</p>

<ul>
  <li>Microsoft Endpoint Manager ConfigMgr &amp; Intune</li>
  <li>Microsoft Security</li>
  <li>Microsoft Defender</li>
  <li>Microsoft Sentinel</li>
  <li>Azure AD</li>
  <li>PowerShell</li>
  <li>Azure Virtual Desktop &amp; Windows 365</li>
</ul>

<p> </p>

<h4 id="presenting">Presenting</h4>

<p><a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will present together with Freek Berson on Infrastructure-as-Code, using Bicep (a StarterKit &amp; Masterclass session) at the Workplace Ninja Summit event.
<a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will join Anton van Pelt in his session on how GO-EUC implemented Infrastructure-as-Code to automatically deploy lab environments at the Workplace Ninja Summit event.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="DevOps" /><category term="Ops" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[The goal of the Workplace Ninja Summit 2023 is to bring the crowd of workplace management and security ninjas together to share their knowledge, learn together. This covers topics around management of endpoints with configuration manager and Intune, as well virtual desktops and the complete security stack of Microsoft.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2023-WorkplaceNinjasSummit-Baden.jpg" /><media:content medium="image" url="/assets/images/events/2023-WorkplaceNinjasSummit-Baden.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">EUC Masters Retreat 2023</title><link href="/events/2023/02/26/eucmasters-arizona-2023.html" rel="alternate" type="text/html" title="EUC Masters Retreat 2023" /><published>2023-02-26T00:00:00+00:00</published><updated>2023-02-26T00:00:00+00:00</updated><id>/events/2023/02/26/eucmasters-arizona-2023</id><content type="html" xml:base="/events/2023/02/26/eucmasters-arizona-2023.html"><![CDATA[<p>he EUC Masters Retreat format provides practical learning opportunities by allowing you to work side by side with peers and hand-picked experts. Topics are selected by attendees and you determine how much time you want to spend in each area.</p>

<p>The EUC Masters Retreat gives you the time you need to ask, learn, explore and share. The weekend retreat format provides flexible timing, inspiring spaces and like minded individuals.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="event" /><summary type="html"><![CDATA[he EUC Masters Retreat format provides practical learning opportunities by allowing you to work side by side with peers and hand-picked experts. Topics are selected by attendees and you determine how much time you want to spend in each area.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2023-eucmasters-arizona.png" /><media:content medium="image" url="/assets/images/events/2023-eucmasters-arizona.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Bicep: a more intuitive way to deploy Azure resources</title><link href="/posts/2023/02/25/bicep-getting-started.html" rel="alternate" type="text/html" title="Bicep: a more intuitive way to deploy Azure resources" /><published>2023-02-25T11:00:00+00:00</published><updated>2023-02-25T11:00:00+00:00</updated><id>/posts/2023/02/25/bicep-getting-started</id><content type="html" xml:base="/posts/2023/02/25/bicep-getting-started.html"><![CDATA[<h1 id="bicep-a-more-intuitive-way-to-deploy-azure-resources">Bicep: a more intuitive way to deploy Azure resources</h1>

<p>When it comes to deploying resources on Azure, defining infrastructure can be a complex and time-consuming process. Fortunately, there’s a new tool available that simplifies the process and saves time and resources in the process: Bicep. <br />
 
 </p>

<h2 id="what-is-bicep">What is Bicep?</h2>
<p>Bicep is a domain-specific language (DSL) used to describe and deploy Azure resources. It is a declarative language that provides a simplified way of defining Azure resources in comparison to traditional JSON or YAML templates.</p>

<p>Bicep is open source and is developed and maintained by Microsoft. <br />
 
 </p>

<h2 id="why-use-bicep">Why use Bicep?</h2>

<p>Bicep simplifies the process of deploying Azure resources by allowing users to write more concise code. It also provides a simplified syntax that is easier to read and understand than traditional JSON or YAML templates.</p>

<p>In addition, Bicep provides features like parameterization, reusable modules, and better error messages that make it easier to build and maintain infrastructure. <br />
 
 </p>

<h2 id="how-does-bicep-work">How does Bicep work?</h2>

<p>Bicep files have a .bicep file extension and are compiled into ARM templates before deployment. This means that any Azure resource that can be defined using ARM templates can also be defined using Bicep.</p>

<p>Here’s an example of what Bicep code looks like:</p>

<pre><code class="language-bicep">param storageAccountName string
param location string

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}
</code></pre>

<p>This code creates an Azure storage account using the <code class="language-plaintext highlighter-rouge">Microsoft.Storage/storageAccounts@2021-04-01</code> resource type. The <code class="language-plaintext highlighter-rouge">param</code> statements at the top of the file define two parameters that can be passed in when the Bicep file is deployed. <br />
 
 </p>

<h2 id="how-to-get-started-with-bicep">How to get started with Bicep?</h2>

<p>To get started with Bicep, you’ll need to install the Bicep CLI. The CLI is available for Windows, macOS, and Linux.</p>

<p>Once you have the Bicep CLI installed, you can create a new Bicep file using your favorite text editor or IDE.</p>

<p><em>Note: I personally recommend using VS Code, combined with the <a href="https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep" target="_blank">Bicep Extension for VS Code</a>, as your code editor to use the extension supporting features, like scaffolding, snippets and linter to make create your first .bicep file a breeze!</em>
 
 </p>

<p>You can also use the <code class="language-plaintext highlighter-rouge">bicep new</code> command to generate a basic Bicep file.</p>

<p>After you’ve created your Bicep file, you can use the <code class="language-plaintext highlighter-rouge">bicep build</code> command to compile it into an ARM template. You can then use the <code class="language-plaintext highlighter-rouge">az deployment group create</code> command to deploy the ARM template.</p>

<p><em>Note: As of Azure CLI version 2.20.0 and Azure PowerShell 5.6.0 you no longer need to compile the bicep template to an ARM template, but can use the bicep template as direct input for the resource deployment commands. Azure CLI and Azure PowerShell will automatically compile your bicep template before and offer the compiled ARM template to the Azure Resource Manager</em> <br />
 
 </p>

<h2 id="conclusion">Conclusion</h2>

<p>Bicep is a powerful tool for deploying Azure resources. It simplifies the process of defining infrastructure by providing a more concise syntax and additional features like parameterization and reusable modules.</p>

<p>If you’re new to Bicep, I recommend checking out the official Bicep documentation and experimenting with some sample code to get a feel for how it works.</p>

<p>With Bicep, you can create and manage your Azure infrastructure in a more streamlined and efficient way, saving time and resources in the process. So why not give it a try and see how it can help you with your Azure deployments?</p>]]></content><author><name>cdo-ninja</name></author><category term="posts" /><category term="blog" /><summary type="html"><![CDATA[Bicep: a more intuitive way to deploy Azure resources]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/posts/bicep-logo-730x350.png" /><media:content medium="image" url="/assets/images/posts/bicep-logo-730x350.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Amsterdam Ruby Meetup</title><link href="/events/2022/08/13/rubymeetup-amsterdam-2022.html" rel="alternate" type="text/html" title="Amsterdam Ruby Meetup" /><published>2022-08-13T00:00:00+00:00</published><updated>2022-08-13T00:00:00+00:00</updated><id>/events/2022/08/13/rubymeetup-amsterdam-2022</id><content type="html" xml:base="/events/2022/08/13/rubymeetup-amsterdam-2022.html"><![CDATA[<p>Hello Rubyists 👋🧑‍🎨,</p>

<p>Take two. Welcome to the summer edition of the Amsterdam Ruby meetup! Let’s talk about hosting and how our hobby projects are going.</p>

<p>We are doing this live at WeTravel in Amsterdam. WeTravel will not just host our event, they will also provide us with food 🥦 and drinks 🧃. We have added an attendee limit this time. We may change this before the event itself, so keep an eye on this space if you don’t secure a spot right away!</p>

<p>It goes without saying that we have a great set of speakers again. Our first guest is the marvelous, Esther Barthel who will talk to us about using your dev skils to transition Ops to the Cloud.</p>

<p>Our second guest is the astonishing Aidan Rudkovskyi who will talk about his NotForSale project.</p>

<p>This location is completely (wheelchair) accessible. Don’t feel like joining in person? The live stream can be found on YouTube, and it will stay up after the event. See you there! Can’t wait ✨ 🌈</p>

<p>Floor, Arno, Tom, Rayta</p>

<p> </p>

<h4 id="presenting">Presenting</h4>
<p><a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will share how she transitioned from an Ops role to a DevOps role at the Amsterdam Ruby Meetup.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="DevOps" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[Hello Rubyists 👋🧑‍🎨,]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2022-amsrubymeetup-amsterdam.jpeg" /><media:content medium="image" url="/assets/images/events/2022-amsrubymeetup-amsterdam.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">TechDays: Putting the “Ops” into DevOps</title><link href="/events/2022/08/13/techdays-online-2022.html" rel="alternate" type="text/html" title="TechDays: Putting the “Ops” into DevOps" /><published>2022-08-13T00:00:00+00:00</published><updated>2022-08-13T00:00:00+00:00</updated><id>/events/2022/08/13/techdays-online-2022</id><content type="html" xml:base="/events/2022/08/13/techdays-online-2022.html"><![CDATA[<p>Putting the “Ops” into DevOps</p>

<p> </p>

<h4 id="presenting">Presenting</h4>
<p><a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will start this series with an introduction to DevOps and the transition to DevOps from an Ops perspective at this online TechDays event.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="DevOps" /><category term="Ops" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[Putting the “Ops” into DevOps]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2022-TechDays-Virtual.png" /><media:content medium="image" url="/assets/images/events/2022-TechDays-Virtual.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Workplace Ninja Summit 2022</title><link href="/events/2022/08/13/workplaceninjasummit-lucerne-2022.html" rel="alternate" type="text/html" title="Workplace Ninja Summit 2022" /><published>2022-08-13T00:00:00+00:00</published><updated>2022-08-13T00:00:00+00:00</updated><id>/events/2022/08/13/workplaceninjasummit-lucerne-2022</id><content type="html" xml:base="/events/2022/08/13/workplaceninjasummit-lucerne-2022.html"><![CDATA[<p>The goal of the Workplace Ninja Summit 2022 is to bring the crowd of workplace management and security ninjas together to share their knowledge, learn together. This covers topics around management of endpoints with configuration manager and Intune, as well virtual desktops and the complete security stack of Microsoft.</p>

<p>Covered Topics</p>

<ul>
  <li>Microsoft Endpoint Manager ConfigMgr &amp; Intune</li>
  <li>Microsoft Security</li>
  <li>Microsoft Defender</li>
  <li>Microsoft Sentinel</li>
  <li>Azure AD</li>
  <li>PowerShell</li>
  <li>Azure Virtual Desktop &amp; Windows 365</li>
</ul>

<p> </p>

<h4 id="presenting">Presenting</h4>

<p><a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will present solo on advanced AVD deployment scenarios using Bicep at the Workplace Ninja Summit event.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="DevOps" /><category term="Ops" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[The goal of the Workplace Ninja Summit 2022 is to bring the crowd of workplace management and security ninjas together to share their knowledge, learn together. This covers topics around management of endpoints with configuration manager and Intune, as well virtual desktops and the complete security stack of Microsoft.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2022-WorkplaceNinjasSummit-Lucerne.jpg" /><media:content medium="image" url="/assets/images/events/2022-WorkplaceNinjasSummit-Lucerne.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Azure Bonn Meetup</title><link href="/events/2022/01/05/azurebonn-meetup-2022.html" rel="alternate" type="text/html" title="Azure Bonn Meetup" /><published>2022-01-05T00:00:00+00:00</published><updated>2022-01-05T00:00:00+00:00</updated><id>/events/2022/01/05/azurebonn-meetup-2022</id><content type="html" xml:base="/events/2022/01/05/azurebonn-meetup-2022.html"><![CDATA[<p>We are happy to announce that Esther Barthel will join the Azure Bonn Meetup in January. Esther Barthel is a long member of the meetup and an awesome community woman.</p>

<p>She has many years experience in architecting and building virtual desktop environments with Citrix and Microsoft and was awarded as Citrix CTP and Microsoft MVP.</p>

<p>Transitioning Ops to the Cloud, adding Dev skills to the mix
This session offers tips and tricks to add core Developer skills to your skillset, so you can transition from (on-premises) Operations to a Cloud DevOps role. This session will zoom in on some basic knowledge and terminology that will make it easier to understand the shift in work and competences for DevOps engineers.</p>

<p>Keep in mind that this session is not a technical deep dive that helps you pick the right tool for the job at hand, but it focuses on some basic knowledge of DevOps processes that will help you to plan the next steps in your career and pick those DevOps competences that will put the fun back into your work.</p>

<p>We plan this meetup again as in-person event and will bring futher information in January.</p>

<p> </p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[We are happy to announce that Esther Barthel will join the Azure Bonn Meetup in January. Esther Barthel is a long member of the meetup and an awesome community woman.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2022-azurebonn-meetup.png" /><media:content medium="image" url="/assets/images/events/2022-azurebonn-meetup.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Scottish Summit 2022</title><link href="/events/2022/01/03/scottishsummit-glasgow-2022.html" rel="alternate" type="text/html" title="Scottish Summit 2022" /><published>2022-01-03T00:00:00+00:00</published><updated>2022-01-03T00:00:00+00:00</updated><id>/events/2022/01/03/scottishsummit-glasgow-2022</id><content type="html" xml:base="/events/2022/01/03/scottishsummit-glasgow-2022.html"><![CDATA[<p>Scottish Summit is back and bigger than ever.
It’s the fourth year of this widely renowned Microsoft Community event – and it certainly will not disappoint!</p>

<p>In 2022 our learning and development sessions span two whole days to cater for huge demand and an influx of highly talented speakers looking to inspire an entire community.</p>

<p>This is your chance to hear the latest and greatest in Microsoft technology trends, innovations and partner initiatives from professionals in the field with vast knowledge and experience.</p>

<p> </p>

<h4 id="presenting">Presenting</h4>
<p><a href="/members/esther-barthel" target="_blank">Esther Barthel</a> will present, together with <a href="https://www.linkedin.com/in/freekberson/" target="_blank">Freek Berson</a>, an advanced Bicep masterclass at the Scottish Summit 2022.</p>]]></content><author><name>[&quot;cdo-ninja&quot;]</name></author><category term="events" /><category term="Bicep" /><category term="event" /><category term="presenting" /><summary type="html"><![CDATA[Scottish Summit is back and bigger than ever. It’s the fourth year of this widely renowned Microsoft Community event – and it certainly will not disappoint!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/events/2022-scottishsummit-glasgow.png" /><media:content medium="image" url="/assets/images/events/2022-scottishsummit-glasgow.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>