<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: dsly</title>
    <description>The latest articles on DEV Community by dsly (@dsly).</description>
    <link>https://dev.to/dsly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3879327%2F7e9b17e8-f265-4af8-9466-baa5c6f3b21a.jpeg</url>
      <title>DEV Community: dsly</title>
      <link>https://dev.to/dsly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dsly"/>
    <language>en</language>
    <item>
      <title>The Canvas breach and the cost of multi-tenant blast radius</title>
      <dc:creator>dsly</dc:creator>
      <pubDate>Mon, 11 May 2026 00:15:56 +0000</pubDate>
      <link>https://dev.to/dsly/the-canvas-breach-and-the-cost-of-multi-tenant-blast-radius-1072</link>
      <guid>https://dev.to/dsly/the-canvas-breach-and-the-cost-of-multi-tenant-blast-radius-1072</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://arkensec.com/blog/canvas-may-2026-multi-tenant-breach" rel="noopener noreferrer"&gt;arkensec.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Between April 29 and May 7, 2026, ShinyHunters claimed two consecutive breaches of Instructure — the company behind Canvas, the LMS running on 41% of North American higher ed. The group says it pulled 3.65 TB and 275 million records spanning 8,809 schools, then defaced Canvas login pages when Instructure shipped patches instead of negotiating.&lt;/p&gt;

&lt;p&gt;No exotic CVE. No kernel exploit. The stated vector: "an issue related to its Free-For-Teacher accounts."&lt;/p&gt;

&lt;p&gt;8,809 schools. One free-tier sign-up flow.&lt;/p&gt;

&lt;p&gt;That number is the point of this post. I want to walk through what the breach shape tells us about multi-tenant API design, where the trust boundary likely failed, and what developers building SaaS on shared infrastructure can actually do about it — with code you can run today.&lt;/p&gt;




&lt;h2&gt;
  
  
  The timeline, briefly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apr 29&lt;/td&gt;
&lt;td&gt;Instructure detects unauthorized activity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 1&lt;/td&gt;
&lt;td&gt;Public disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 2&lt;/td&gt;
&lt;td&gt;Instructure says breach contained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 3&lt;/td&gt;
&lt;td&gt;ShinyHunters posts ransom note on Ransomware.live, claims 275M records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 5&lt;/td&gt;
&lt;td&gt;8,809 affected schools published, including Harvard, Princeton, entire NC K-12 system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 7&lt;/td&gt;
&lt;td&gt;Canvas login pages defaced with second ransom note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 12&lt;/td&gt;
&lt;td&gt;ShinyHunters' stated negotiation deadline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What leaked: names, email addresses, student IDs, private messages. What didn't (per Instructure): passwords, DOBs, government IDs, financial data. That distinction matters less than institutions are framing it — I'll get to why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the seam probably was
&lt;/h2&gt;

&lt;p&gt;Instructure said the vector was "an issue related to its Free-For-Teacher accounts." FFT is a no-cost self-serve tier. Sign up with a teacher email, get a Canvas instance, no procurement process, no SSO mandate.&lt;/p&gt;

&lt;p&gt;That description — free tier, self-serve, no institutional controls — points at a specific vulnerability class: &lt;strong&gt;BOLA (Broken Object Level Authorization)&lt;/strong&gt;, which sits at the top of the OWASP API Security Top 10.&lt;/p&gt;

&lt;p&gt;Here's what BOLA looks like in a multi-tenant context. Imagine an API endpoint like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/courses/{courseId}/students
Authorization: Bearer &amp;lt;fft_token&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server validates that the token is &lt;em&gt;authentic&lt;/em&gt; but doesn't validate that the token's tenant scope &lt;em&gt;owns&lt;/em&gt; &lt;code&gt;courseId&lt;/code&gt;, a free-tier user can enumerate course IDs belonging to paid institutional tenants. The auth check passes. The object-level check doesn't exist.&lt;/p&gt;

&lt;p&gt;A minimal Python script to demonstrate the enumeration pattern (against your own test environment — don't run this against systems you don't own):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-canvas-test-instance.instructure.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_fft_test_token_here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Enumerate course IDs sequentially — BOLA is often this simple
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;probe_course_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/courses/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/students&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;student_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACCESSIBLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[!] Course &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; accessible — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; students&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Respect rate limits in testing
&lt;/span&gt;        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;probe_course_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1050&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a properly scoped multi-tenant API, that request should return &lt;code&gt;403 Forbidden&lt;/code&gt; the moment &lt;code&gt;courseId&lt;/code&gt; belongs to a different tenant — regardless of whether the token is valid. The token proves identity. Tenant-scoped authorization proves &lt;em&gt;permission&lt;/em&gt;. Those are two separate checks, and collapsing them is how you get 8,809 schools exposed through one free account.&lt;/p&gt;

&lt;p&gt;I'm not claiming this is exactly what happened. Instructure hasn't published a post-mortem. But "FFT account issue → 9,000-school data pull" fits this shape precisely.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to test your own API for BOLA
&lt;/h2&gt;

&lt;p&gt;If you're building a multi-tenant SaaS, here's a practical checklist. Run this against your staging environment before you ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create two isolated test tenants&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Tenant A — simulates a free/low-trust tier&lt;/span&gt;
&lt;span class="nv"&gt;TENANT_A_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"token_for_tenant_a"&lt;/span&gt;
&lt;span class="nv"&gt;TENANT_A_RESOURCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"resource_owned_by_tenant_a"&lt;/span&gt;

&lt;span class="c"&gt;# Tenant B — simulates a paid/institutional tier&lt;/span&gt;
&lt;span class="nv"&gt;TENANT_B_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"token_for_tenant_b"&lt;/span&gt;
&lt;span class="nv"&gt;TENANT_B_RESOURCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"resource_owned_by_tenant_b"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Attempt cross-tenant object access&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Can Tenant A's token read Tenant B's resource?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TENANT_A_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://your-api.example.com/api/v1/resources/&lt;/span&gt;&lt;span class="nv"&gt;$TENANT_B_RESOURCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Expected: 403&lt;/span&gt;
&lt;span class="c"&gt;# Dangerous: 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Check for sequential ID enumeration&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Probe a range of IDs with Tenant A's token&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 50&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TENANT_A_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"https://your-api.example.com/api/v1/resources/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[BOLA] Resource &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; accessible to wrong tenant — HTTP &lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: Test token namespace isolation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your free tier and paid tier share a token issuer (common with OAuth), verify the token claims include tenant scope and that your middleware enforces it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;  &lt;span class="c1"&gt;# pip install PyJWT
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_tenant_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required_tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Decode token and verify tenant claim matches the resource being accessed.
    This check must happen on EVERY object-level request, not just at login.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Use your actual secret/public key here
&lt;/span&gt;        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;token_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token_tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[WARN] Token has no tenant claim — reject this request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token_tenant&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;required_tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[BOLA] Token tenant &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token_tenant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; != resource tenant &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;required_tenant_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="c1"&gt;# In your request handler:
# if not validate_tenant_scope(request.token, resource.tenant_id):
#     return 403
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware pattern that actually prevents this — enforce tenant scope at the data layer, not just the route layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Don't do this — tenant check only at the route level
&lt;/span&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/v1/courses/&amp;lt;course_id&amp;gt;/students&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@require_auth&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_students&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# No tenant check here — BOLA waiting to happen
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM students WHERE course_id = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Do this — tenant scope enforced in the query itself
&lt;/span&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/v1/courses/&amp;lt;course_id&amp;gt;/students&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@require_auth&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_students&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;  &lt;span class="c1"&gt;# Extracted from validated JWT
&lt;/span&gt;    &lt;span class="n"&gt;students&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM students WHERE course_id = ? AND tenant_id = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;  &lt;span class="c1"&gt;# Tenant scope baked into every query
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;students&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Don't leak whether the resource exists
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;students&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second pattern is the one that would have contained the blast radius. The query physically can't return data from another tenant because the tenant ID is a required predicate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the leaked data is more dangerous than it looks
&lt;/h2&gt;

&lt;p&gt;Instructure confirmed no passwords leaked. Universities are framing this as "limited exposure." I'd push back on that framing.&lt;/p&gt;

&lt;p&gt;Names + school emails + student IDs is a complete targeting package for spear phishing. Canvas users are &lt;em&gt;conditioned&lt;/em&gt; to click Canvas links. They get Canvas notifications constantly. A phishing email that says "Your Canvas submission was flagged — review here" sent to a real student email, referencing their real student ID, from a domain that looks like &lt;code&gt;canvas-notifications-[school].com&lt;/code&gt; — that's a high-conversion attack.&lt;/p&gt;

&lt;p&gt;The data that leaked is the data you need to make phishing believable. That's the threat model institutions should be briefing against right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to run this week if you're on the affected list
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check whether your Canvas subdomain is still serving the defacement payload:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check for ShinyHunters signatures in the current login page&lt;/span&gt;
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt; https://&amp;lt;your-school&amp;gt;.instructure.com/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'shinyhunters|negotiate a settlement|breached'&lt;/span&gt;

&lt;span class="c"&gt;# Verify security headers haven't been tampered with&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://&amp;lt;your-school&amp;gt;.instructure.com/login &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'set-cookie|strict-transport-security|x-frame-options|content-security-policy'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output for a clean instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;strict-transport-security: max-age=31536000; includeSubDomains
x-frame-options: SAMEORIGIN
set-cookie: _csrf_token=...; Secure; HttpOnly; SameSite=Lax
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing &lt;code&gt;Secure&lt;/code&gt; or &lt;code&gt;HttpOnly&lt;/code&gt; on session cookies, or a missing &lt;code&gt;Strict-Transport-Security&lt;/code&gt; header, means something changed. Investigate before assuming it's fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your DNS for subdomain takeover exposure&lt;/strong&gt; (a common follow-on attack when attacker has your domain structure from leaked data):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check for dangling CNAME records pointing at unclaimed cloud resources&lt;/span&gt;
dig CNAME canvas.&amp;lt;your-school&amp;gt;.edu
dig CNAME lms.&amp;lt;your-school&amp;gt;.edu

&lt;span class="c"&gt;# If the CNAME points at *.instructure.com and that tenant no longer exists,&lt;/span&gt;
&lt;span class="c"&gt;# you have a subdomain takeover risk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Force SSO password rotation for Canvas-linked accounts.&lt;/strong&gt; If your institution uses SSO (Shibboleth, Azure AD, Okta), the Canvas breach didn't expose those passwords directly — but users who set a local Canvas password and reused it elsewhere are exposed. Rotate anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  The broader pattern: sector-targeting is math
&lt;/h2&gt;

&lt;p&gt;This is ShinyHunters' third edtech breach in 2026. Infinite Campus in the spring. McGraw Hill. Now Canvas. That's not random. That's a group that did the math on attacking one consolidated platform versus thousands of individual schools and picked the obvious answer.&lt;/p&gt;

&lt;p&gt;Per Mandiant's M-Trends 2026, nearly 30% of CVEs get exploited within 24 hours of disclosure. Time-to-exploit has effectively gone negative — attackers are finding bugs before defenders finish reading the advisory. When you combine that with the consolidation of edtech into a handful of platforms, the attack surface math gets uncomfortable fast.&lt;/p&gt;

&lt;p&gt;I wrote about the same compounding pattern after the &lt;a href="https://arkensec.com/blog/vercel-april-2026-oauth-supply-chain-breach" rel="noopener noreferrer"&gt;Vercel OAuth supply chain breach in April&lt;/a&gt;. Different vector, same shape: one shared platform, one trust-boundary failure, every downstream customer holding the bill.&lt;/p&gt;

&lt;p&gt;Your security posture isn't just your security posture. It's your posture multiplied by the weakest tenant boundary in every multi-tenant SaaS you depend on. Canvas just made that concrete for 8,809 institutions at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix isn't "better vendor selection"
&lt;/h2&gt;

&lt;p&gt;I want to be honest about the limitation here. Telling institutions to "pick more secure vendors" is not actionable advice. Canvas is dominant because it works, it integrates with everything, and switching LMS platforms is a multi-year project that costs millions. The consolidation that created this blast radius is also the consolidation that made modern edtech functional.&lt;/p&gt;

&lt;p&gt;The actual levers are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Demand tenant isolation attestations from vendors.&lt;/strong&gt; SOC 2 Type II covers a lot of things, but it doesn't specifically attest to BOLA-class API isolation. Ask vendors directly: how do you test cross-tenant object access? What's your API security testing cadence? If they can't answer that, you know something.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat vendor breaches as your breach for incident response purposes.&lt;/strong&gt; Don't wait for the vendor to tell you what to do. The moment Canvas appeared on Ransomware.live, every institution should have started their own IR process — not waiting for Instructure's May 6 "back to normal" announcement.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run your own external attack surface checks regularly.&lt;/strong&gt; You can't control what's inside your vendor's perimeter. You can control what's visible on your own domain. Surface-level checks on DNS, TLS, exposed admin endpoints, and subdomain takeover risk take minutes and catch the adjacent exposures an attacker would chain off leaked data.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://arkensec.com/" rel="noopener noreferrer"&gt;free ArkenSec scan&lt;/a&gt; runs 17 checks across those categories, takes about two minutes, and doesn't require a signup. It won't tell you if your Canvas tenant was in the leaked dataset — only Instructure can tell you that. It will surface what an attacker with your domain name already sees.&lt;/p&gt;




&lt;p&gt;The Canvas breach is a multi-tenant architecture problem that got dressed up as a vendor security failure. Both things are true. But the architectural problem is the one that scales — and the one that developers building SaaS today are in a position to actually fix.&lt;/p&gt;

&lt;p&gt;Test your tenant boundaries. Enforce scope at the data layer. Don't collapse authentication and authorization into a single check. Those three things wouldn't have prevented ShinyHunters from finding the seam, but they would have made the seam a lot harder to walk through.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>SOC 2 Type II readiness is an evidence-velocity problem</title>
      <dc:creator>dsly</dc:creator>
      <pubDate>Mon, 27 Apr 2026 05:00:17 +0000</pubDate>
      <link>https://dev.to/dsly/soc-2-type-ii-readiness-is-an-evidence-velocity-problem-3k60</link>
      <guid>https://dev.to/dsly/soc-2-type-ii-readiness-is-an-evidence-velocity-problem-3k60</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://arkensec.com/blog/soc-2-type-ii-readiness-evidence-velocity" rel="noopener noreferrer"&gt;arkensec.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My last SOC 2 Type II kickoff call lasted 82 minutes. The auditor asked for seven specific artifacts in the first ten, and I had four of them. The other three — evidence of vulnerability scan cadence on a defined schedule, documented remediation SLAs with timestamps, and a current third-party penetration test report — cost three weeks and $14,000 to produce mid-engagement.&lt;/p&gt;

&lt;p&gt;I've now sat in twelve of these kickoffs across both sides of the table. The same thing breaks every time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Evidence velocity, not documentation, is what blocks Series A SaaS from SOC 2 Type II. Closing the velocity gap saves six months and $20K–$50K.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What "evidence velocity" actually means
&lt;/h2&gt;

&lt;p&gt;SOC 2 Type II readiness means your control environment can produce timestamped, auditor-legible evidence of every in-scope control operating consistently across a multi-month observation period — typically six months on a first audit, twelve on subsequent ones.&lt;/p&gt;

&lt;p&gt;Readiness is not whether your policies exist. It's whether the artifacts those policies promise can be sampled at any random week and handed over in under five minutes.&lt;/p&gt;

&lt;p&gt;Auditors don't review your entire observation period sequentially. They sample. They pick week 12, week 23, week 31, and ask you to produce evidence that each control fired on those specific dates. If you can produce it for week 12 but not week 23, the control fails.&lt;/p&gt;

&lt;p&gt;That's the velocity problem. Not "do you have a scanner." But "does the scanner produce a retained, timestamped artifact on a fixed cadence, automatically, every single time."&lt;/p&gt;




&lt;h2&gt;
  
  
  Type I vs. Type II: what actually changes
&lt;/h2&gt;

&lt;p&gt;Type I is a snapshot. Type II is a recording. The enterprise buyer blocking your deal wants the recording.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Type I&lt;/th&gt;
&lt;th&gt;Type II&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Question answered&lt;/td&gt;
&lt;td&gt;Are controls designed correctly today?&lt;/td&gt;
&lt;td&gt;Did controls operate effectively over the observation window?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observation period&lt;/td&gt;
&lt;td&gt;Point-in-time&lt;/td&gt;
&lt;td&gt;3–12 months (6 is typical first audit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence required&lt;/td&gt;
&lt;td&gt;Policies, configs, sample artifacts&lt;/td&gt;
&lt;td&gt;Continuous artifacts across the full window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auditor sampling&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Random weeks across the period&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer credibility&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Required for most enterprise procurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical cost&lt;/td&gt;
&lt;td&gt;$10K–$20K&lt;/td&gt;
&lt;td&gt;$25K–$60K (audit fee + readiness + tooling)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Missing policy&lt;/td&gt;
&lt;td&gt;Missing artifact for sampled week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Enterprise procurement teams stopped accepting Type I as a substitute around 2022. If you're going through the work, plan for Type II from day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three Trust Services Criteria that eat Series A teams
&lt;/h2&gt;

&lt;p&gt;The AICPA publishes five Trust Services Criteria: Security (mandatory), Availability, Processing Integrity, Confidentiality, and Privacy. Almost every Series A SaaS scopes the first audit to Security only — right call. Inside Security, auditors walk roughly sixty points of focus across nine Common Criteria.&lt;/p&gt;

&lt;p&gt;Three of those produce most of the pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CC6.1&lt;/strong&gt; — logical and physical access controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC7.1&lt;/strong&gt; — detection of security events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC7.2&lt;/strong&gt; — system monitoring for vulnerabilities and malicious code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every other criterion has a policy answer. These three demand continuous evidence. That's where readiness dies.&lt;/p&gt;

&lt;p&gt;First readiness assessments for 25-person SaaS teams typically return 40–80 gaps. Roughly three-quarters follow the same pattern: the control is written into the policy, it's occasionally enforced in practice, and nobody has collected evidence of continuous operation across the observation window.&lt;/p&gt;




&lt;h2&gt;
  
  
  What auditor-acceptable evidence looks like (with examples)
&lt;/h2&gt;

&lt;p&gt;A useful rule: if you can't produce the evidence in under five minutes from a cold start, it doesn't exist for audit purposes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format that works&lt;/th&gt;
&lt;th&gt;Format that fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Immutable logs with timestamps (CloudTrail, GitHub audit log, Okta system log to a dated S3 bucket)&lt;/td&gt;
&lt;td&gt;"We have logging on"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool-generated reports with scan-date header, retained for the full observation window&lt;/td&gt;
&lt;td&gt;Screenshots saved to someone's laptop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticket artifacts with state transitions (Jira/Linear: opened → assigned → remediated → closed, each timestamped)&lt;/td&gt;
&lt;td&gt;"We can regenerate the report"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewer-signed artifacts (named reviewer clicks approval on a defined schedule)&lt;/td&gt;
&lt;td&gt;A Slack thumbs-up emoji&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron-driven scan output landing in a write-once retention bucket on a fixed cadence&lt;/td&gt;
&lt;td&gt;"We scan when we deploy"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let me show you what the right side of that table looks like in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring CloudTrail to S3 with immutable retention
&lt;/h3&gt;

&lt;p&gt;This is the foundation for CC6.1 and CC7.1 evidence. CloudTrail logs API calls; the S3 bucket with Object Lock makes them tamper-evident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a dedicated audit evidence bucket with versioning + Object Lock&lt;/span&gt;
aws s3api create-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-company-soc2-evidence &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--object-lock-enabled-for-bucket&lt;/span&gt;

&lt;span class="c"&gt;# Enable versioning (required for Object Lock)&lt;/span&gt;
aws s3api put-bucket-versioning &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-company-soc2-evidence &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--versioning-configuration&lt;/span&gt; &lt;span class="nv"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Enabled

&lt;span class="c"&gt;# Set a default retention policy (COMPLIANCE mode, 365 days)&lt;/span&gt;
aws s3api put-object-lock-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-company-soc2-evidence &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--object-lock-configuration&lt;/span&gt; &lt;span class="s1"&gt;'{
    "ObjectLockEnabled": "Enabled",
    "Rule": {
      "DefaultRetention": {
        "Mode": "COMPLIANCE",
        "Days": 365
      }
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create a CloudTrail trail that writes to that bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudtrail create-trail &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; soc2-audit-trail &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-bucket-name&lt;/span&gt; your-company-soc2-evidence &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include-global-service-events&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--is-multi-region-trail&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-log-file-validation&lt;/span&gt;

aws cloudtrail start-logging &lt;span class="nt"&gt;--name&lt;/span&gt; soc2-audit-trail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--enable-log-file-validation&lt;/code&gt; is the part most people skip. It creates SHA-256 digest files that let you prove the logs weren't modified after the fact. Auditors who know what they're looking at will ask for this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pulling the Okta system log to a dated evidence path
&lt;/h3&gt;

&lt;p&gt;For CC6.1 access reviews, you need the IdP log retained with a timestamp path an auditor can navigate. Here's a minimal Python script that pulls the Okta system log daily and writes it to a dated S3 prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;OKTA_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-org.okta.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;OKTA_API_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# use Secrets Manager in prod
&lt;/span&gt;&lt;span class="n"&gt;S3_BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-company-soc2-evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pull_okta_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;microsecond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSWS &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;OKTA_API_TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;since&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;until&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;OKTA_DOMAIN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="c1"&gt;# Okta paginates via Link header
&lt;/span&gt;        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# params only on first request
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_to_s3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta-logs/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y/%m/%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/system-log.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Uploaded &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; events to s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;yesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pull_okta_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;upload_to_s3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this as a Lambda on a daily EventBridge schedule. The S3 path structure (&lt;code&gt;okta-logs/2025/01/15/system-log.json&lt;/code&gt;) is what makes the auditor's job easy — they can navigate directly to the date they sampled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# EventBridge rule to trigger daily at 01:00 UTC&lt;/span&gt;
aws events put-rule &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; okta-log-daily &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schedule-expression&lt;/span&gt; &lt;span class="s2"&gt;"cron(0 1 * * ? *)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--state&lt;/span&gt; ENABLED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  CC7.2: the highest evidence-velocity gap
&lt;/h2&gt;

&lt;p&gt;CC7.2 requires you to monitor systems for vulnerabilities and respond on a defined timeline. What auditors want:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A scanning cadence documented in policy (weekly external, monthly internal is the most defensible default)&lt;/li&gt;
&lt;li&gt;A documented remediation SLA aligned to &lt;a href="https://csrc.nist.gov/pubs/sp/800/40/r4/final" rel="noopener noreferrer"&gt;NIST SP 800-40r4&lt;/a&gt;: 30 days for critical, 60 for high, 90 for medium&lt;/li&gt;
&lt;li&gt;Evidence that findings are triaged against the SLA and closed or risk-accepted in writing, for every scan across the entire window&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;CISA Known Exploited Vulnerabilities catalog&lt;/a&gt; is the cleanest external benchmark for which findings warrant the tight end of the SLA. If a CVE is in KEV, treat it as critical regardless of your internal CVSS scoring.&lt;/p&gt;

&lt;p&gt;The failure mode isn't having the scanner. It's running the scanner manually, inconsistently, and never retaining the reports against a sampling schedule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automating vulnerability scan evidence with a cron job and S3
&lt;/h3&gt;

&lt;p&gt;If you're running Nuclei against your external perimeter, here's a minimal wrapper that produces a timestamped, retained artifact on every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# scan-and-retain.sh&lt;/span&gt;
&lt;span class="c"&gt;# Run this on a fixed cron schedule that matches your written policy exactly.&lt;/span&gt;
&lt;span class="c"&gt;# If your policy says "weekly every Monday at 02:00 UTC", this cron runs&lt;/span&gt;
&lt;span class="c"&gt;# weekly every Monday at 02:00 UTC. Not "roughly weekly." Exactly.&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;your&lt;/span&gt;&lt;span class="p"&gt;-domain.com&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;BUCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-company-soc2-evidence"&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +&lt;span class="s2"&gt;"%Y-%m-%dT%H:%M:%SZ"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DATE_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +&lt;span class="s2"&gt;"%Y/%m/%d"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;REPORT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/nuclei-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Starting scan of &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; at &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Run Nuclei with severity filter and JSON output&lt;/span&gt;
nuclei &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-target&lt;/span&gt; &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-severity&lt;/span&gt; critical,high,medium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-json-export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPORT_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-tags&lt;/span&gt; cve,exposure,misconfiguration

&lt;span class="c"&gt;# Add scan metadata envelope&lt;/span&gt;
&lt;span class="nv"&gt;METADATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; target &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; timestamp &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; policy_cadence &lt;span class="s2"&gt;"weekly"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; scanner &lt;span class="s2"&gt;"nuclei"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{
    scan_metadata: {
      target: $target,
      scan_timestamp: $timestamp,
      policy_cadence: $policy_cadence,
      scanner: $scanner,
      soc2_control: "CC7.2",
      nist_reference: "SP 800-40r4"
    }
  }'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Merge metadata with findings&lt;/span&gt;
jq &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'.[0] * {findings: .[1]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$METADATA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPORT_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/final-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;

&lt;span class="c"&gt;# Upload to dated S3 path&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/final-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUCKET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/vulnerability-scans/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATE_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/nuclei-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Evidence retained at s3://&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUCKET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/vulnerability-scans/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATE_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;

&lt;span class="c"&gt;# Cleanup&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPORT_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/final-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron entry, if your policy says weekly on Mondays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 2 * * 1 /opt/scripts/scan-and-retain.sh your-domain.com &amp;gt;&amp;gt; /var/log/soc2-scans.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metadata envelope is the part most people skip. When an auditor pulls the artifact for week 23, they need to see the scan timestamp, the target, and the control it satisfies — without you having to explain it verbally. Put it in the artifact itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking remediation SLAs in Jira with automation
&lt;/h3&gt;

&lt;p&gt;The other half of CC7.2 evidence is showing that findings get triaged and closed within the documented SLA. Here's a Jira automation rule (in JSON, importable via the automation library) that sets a due date on vulnerability tickets based on severity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Set CC7.2 remediation due date by severity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trigger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"component"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TRIGGER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jira.issue.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conditions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"component"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONDITION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jira.issue.fields.condition"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"labels"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONTAINS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vulnerability"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"component"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jira.issue.edit.fields"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"duedate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"expression"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{#if issue.priority.name == 'Critical'}}{{now.plusDays(30)}}{{else if issue.priority.name == 'High'}}{{now.plusDays(60)}}{{else}}{{now.plusDays(90)}}{{/if}}"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"customfield_soc2_control"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CC7.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"customfield_nist_sla"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SP 800-40r4"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state transitions (opened → assigned → remediated → closed) are what the auditor samples. Every transition is timestamped by Jira automatically. The due date field makes SLA compliance visible in a single query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;labels = vulnerability 
  AND duedate &amp;lt; now() 
  AND status != Done 
ORDER BY priority DESC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this query weekly and screenshot the result to your evidence bucket. Zero results is the evidence. Non-zero results need a risk acceptance artifact.&lt;/p&gt;




&lt;h2&gt;
  
  
  CC7.1: security event detection without a full SOC
&lt;/h2&gt;

&lt;p&gt;CC7.1 requires monitoring for anomalous events with a documented response process. Most Series A teams do this by accident — CloudWatch alarms, Sentry, a dedicated Slack channel, maybe PagerDuty. The gap is usually the absence of a defined severity taxonomy and any artifact showing triage actually happened.&lt;/p&gt;

&lt;p&gt;Vercel's April 2026 disclosure is the worst-case version of CC7.1 failing silently: roughly 22 months between OAuth compromise and detection. Same evidence-velocity lesson, different control. I &lt;a href="https://arkensec.com/blog/vercel-april-2026-oauth-supply-chain-breach" rel="noopener noreferrer"&gt;wrote that one up separately&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What works at Series A scale: a lightweight log aggregator (Panther, Wazuh, or a structured SNS → Lambda → S3 pipeline), a one-page&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vercel's April 2026 Breach Was an OAuth Supply-Chain Attack</title>
      <dc:creator>dsly</dc:creator>
      <pubDate>Tue, 21 Apr 2026 04:38:01 +0000</pubDate>
      <link>https://dev.to/dsly/vercels-april-2026-breach-was-an-oauth-supply-chain-attack-1ckj</link>
      <guid>https://dev.to/dsly/vercels-april-2026-breach-was-an-oauth-supply-chain-attack-1ckj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://arkensec.com/blog/vercel-april-2026-oauth-supply-chain-breach" rel="noopener noreferrer"&gt;arkensec.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most post-mortems this week will frame the Vercel breach as a PaaS story. That's the wrong frame. Next.js wasn't compromised. Turbopack wasn't compromised. Vercel's build pipeline and edge infrastructure did their jobs. What got compromised was a single OAuth grant in a corporate Google Workspace, and the blast radius reached customers because "encrypted at rest" doesn't help when the attacker is holding a live admin session.&lt;/p&gt;

&lt;p&gt;The breach sat undetected for roughly 22 months — mid-2024 to April 19, 2026 — because an OAuth supply-chain attack through an AI SaaS routed around Vercel's hosting defenses entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually happened, in order
&lt;/h2&gt;

&lt;p&gt;The chain, stitched together from Vercel's bulletin, CEO Guillermo Rauch's disclosure thread, Context.ai's own advisory, and early independent write-ups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — The infostealer.&lt;/strong&gt;&lt;br&gt;
A Context.ai employee was infected with &lt;a href="https://malpedia.caad.fkie.fraunhofer.de/details/win.lumma" rel="noopener noreferrer"&gt;Lumma Stealer&lt;/a&gt; in early 2026. The infostealer harvested Google Workspace credentials plus keys for Supabase, Datadog, and Authkit. Nothing in that initial pile was Vercel-specific. The attacker pivoted from there into Context.ai's consumer product — the "AI Office Suite" — and got their hands on OAuth tokens that consumer users had granted to the suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — The pivot.&lt;/strong&gt;&lt;br&gt;
One of those consumer users was a Vercel employee, signed up using their corporate Vercel Google Workspace identity, who had clicked "Allow All" on the OAuth consent screen. The attacker used that token to move into Vercel's Workspace, took over the employee's account, and from there reached Vercel's internal environments. They read customer environment variables that weren't flagged "sensitive."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — The exposure window.&lt;/strong&gt;&lt;br&gt;
Public timelines put the initial OAuth compromise at roughly mid-2024. That's a ~22-month detection gap. Stolen data was listed on BreachForums for $2M by an account claiming the ShinyHunters name; the real ShinyHunters have denied involvement. Attribution is still shaking out.&lt;/p&gt;

&lt;p&gt;The MITRE ATT&amp;amp;CK mapping here is pretty clean: initial access via &lt;a href="https://attack.mitre.org/techniques/T1078/004/" rel="noopener noreferrer"&gt;T1078.004&lt;/a&gt; (Valid Accounts: Cloud Accounts), credential access via &lt;a href="https://attack.mitre.org/techniques/T1555/005/" rel="noopener noreferrer"&gt;T1555.005&lt;/a&gt; (Credentials from Password Stores: Password Managers — infostealer category), and lateral movement through &lt;a href="https://attack.mitre.org/techniques/T1550/001/" rel="noopener noreferrer"&gt;T1550.001&lt;/a&gt; (Use Alternate Authentication Material: Application Access Token).&lt;/p&gt;


&lt;h2&gt;
  
  
  Why "not marked sensitive" is doing so much work
&lt;/h2&gt;

&lt;p&gt;Vercel's environment variable model has two tiers. Variables flagged &lt;strong&gt;sensitive&lt;/strong&gt; are encrypted at rest and unreadable even from internal admin sessions — not even Vercel support can read them back. Everything else is readable by anyone with sufficient control-plane access.&lt;/p&gt;

&lt;p&gt;That second bucket is where most teams leave their secrets, because nobody reads the onboarding docs that closely.&lt;/p&gt;

&lt;p&gt;Database URLs. Stripe secret keys. OpenAI and Anthropic tokens. Webhook signing secrets. S3 access keys. On the seed-stage SaaS teams I've looked at, the split skews heavily toward the non-sensitive tier. Most founders I've asked didn't know the sensitive flag existed until this week.&lt;/p&gt;

&lt;p&gt;I'll say the thing other write-ups won't: Vercel's non-sensitive tier is a UX footgun. The default should be encrypted-at-rest, opt-out to read. Anything else assumes every operator understands the threat model before they paste a key into a form field, and most don't.&lt;/p&gt;

&lt;p&gt;If a credential lived in a non-sensitive Vercel env var any time before April 19, 2026, it was readable from within a compromised admin session. Whether yours specifically was read is what Vercel is still investigating. Whether it &lt;em&gt;could&lt;/em&gt; have been is already settled.&lt;/p&gt;


&lt;h2&gt;
  
  
  How to check if you were exposed
&lt;/h2&gt;

&lt;p&gt;Vercel has been contacting affected customers directly. If you haven't heard from them, you're probably in the unaffected majority. "Probably" is not "confirmed" on a breach with a 22-month exposure window.&lt;/p&gt;

&lt;p&gt;Here's what to run tonight.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Pull the Vercel audit log
&lt;/h3&gt;

&lt;p&gt;Team → Settings → Audit Log. Filter for &lt;code&gt;env.read&lt;/code&gt;, &lt;code&gt;env.list&lt;/code&gt;, and &lt;code&gt;env.getSensitive&lt;/code&gt; events from IPs or user agents you don't recognize, especially across March and April 2026.&lt;/p&gt;

&lt;p&gt;Vercel's audit log exports as JSON. If you want to grep it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Download from Vercel dashboard as JSON, then:&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;vercel-audit-log.json | jq &lt;span class="s1"&gt;'.[] | select(
  .type == "env.read" or
  .type == "env.list" or
  .type == "env.getSensitive"
) | {time: .createdAt, user: .user.email, ip: .meta.ipAddress, type: .type}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything from an IP that isn't your office range or a known CI/CD provider is worth investigating.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Check provider-side logs for every credential that ever lived in Vercel
&lt;/h3&gt;

&lt;p&gt;For AWS — look up any access key that was ever in a Vercel env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudtrail lookup-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lookup-attributes&lt;/span&gt; &lt;span class="nv"&gt;AttributeKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AccessKeyId,AttributeValue&lt;span class="o"&gt;=&lt;/span&gt;AKIAIOSFODNN7EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; 2024-06-01 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end-time&lt;/span&gt; 2026-04-20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Events[].{
    Time:EventTime,
    EventName:EventName,
    Source:EventSource,
    IP:CloudTrailEvent
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Stripe, pull recent API key usage from the dashboard under Developers → Logs, and filter for requests from unexpected IPs or unusual user agents. Stripe's API also exposes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; sk_live_YOUR_KEY: &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"type=api_key.created"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"created[gte]=1717200000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-G&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data[].created'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Grep your repo history for leaked artifacts
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.vercel/&lt;/code&gt; directories sometimes slip past &lt;code&gt;.gitignore&lt;/code&gt;. So do env files that got committed once and then deleted — deletion doesn't remove them from git history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check for .vercel/ artifacts in history&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;".vercel/*"&lt;/span&gt;

&lt;span class="c"&gt;# Search for Vercel tokens committed anywhere in history&lt;/span&gt;
git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"VERCEL_TOKEN"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Broader search for any env-style secrets&lt;/span&gt;
git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_PUBLIC_"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Check if .env was ever committed&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="s2"&gt;".env.local"&lt;/span&gt; &lt;span class="s2"&gt;".env.production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any of those return results, the secret was public the moment that commit was pushed, regardless of what happened with Vercel.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Check for NEXT_PUBLIC_ variables that shipped to the browser
&lt;/h3&gt;

&lt;p&gt;This one catches people off guard. Any variable prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; gets bundled into your client-side JavaScript and served to every visitor. It doesn't matter whether it was marked sensitive in Vercel — it was already public.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In your built output&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_"&lt;/span&gt; .next/static/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;".map"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# Or check what's actually in your production bundle&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yourdomain.com/_next/static/chunks/main-&lt;span class="k"&gt;*&lt;/span&gt;.js | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'NEXT_PUBLIC_\w+'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find API keys in there, rotate them immediately. That's a separate problem from the Vercel breach, but it's the same class of exposure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual fix: lock down your Google Workspace OAuth grants
&lt;/h2&gt;

&lt;p&gt;The check that would have caught this breach upstream isn't a Vercel setting. It's a Google Workspace admin review.&lt;/p&gt;

&lt;p&gt;Open your Workspace admin console: &lt;strong&gt;Security → API Controls → App access control.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You'll see every third-party app that has OAuth access across your organization. For each one, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do I have a written justification for why this app has these scopes?&lt;/li&gt;
&lt;li&gt;Is this app still actively used?&lt;/li&gt;
&lt;li&gt;Did an admin approve this, or did an employee click through a consent screen?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Revoke anything you can't justify. Then set new OAuth grants to require admin approval:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admin Console → Security → API Controls → App access control → Settings → Require admin approval for all third-party apps&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That single config change closes the vector that caught Vercel. It's annoying — employees will file tickets when their new AI tool doesn't connect — and that friction is the point.&lt;/p&gt;

&lt;p&gt;For apps you do approve, enforce least-privilege scopes. "Allow All" is never a reasonable choice for a corporate identity. An AI writing assistant doesn't need &lt;code&gt;https://mail.google.com/&lt;/code&gt; scope. If it's asking for it, that's a red flag about the vendor's architecture, not just their consent screen copy.&lt;/p&gt;

&lt;p&gt;You can also enumerate current OAuth grants programmatically with the Admin SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Requires domain-wide delegation and admin credentials&lt;/span&gt;
&lt;span class="c"&gt;# List all third-party apps with OAuth access&lt;/span&gt;
gam all &lt;span class="nb"&gt;users &lt;/span&gt;show tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/GAM-team/GAM" rel="noopener noreferrer"&gt;GAM&lt;/a&gt; is the open-source Google Workspace admin CLI — if you're managing Workspace at any scale and you're not using it, you're doing it the hard way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to change this week, in order
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Rotate and re-flag credentials.&lt;/strong&gt;&lt;br&gt;
Every deploy token, API key, and database credential that ever lived as a non-sensitive Vercel env var needs rotation. When replacements go back in, mark them sensitive. The flag exists for a reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Vercel CLI — set a sensitive env var&lt;/span&gt;
vercel &lt;span class="nb"&gt;env &lt;/span&gt;add DATABASE_URL production &lt;span class="nt"&gt;--sensitive&lt;/span&gt;

&lt;span class="c"&gt;# Or via API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.vercel.com/v10/projects/YOUR_PROJECT_ID/env"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VERCEL_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "key": "DATABASE_URL",
    "value": "postgres://...",
    "type": "sensitive",
    "target": ["production"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Lock down Google Workspace OAuth.&lt;/strong&gt;&lt;br&gt;
Admin Console → Security → API Controls → App access control. Revoke apps without written justifications. Require admin approval for future grants. Do this before you do anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Turn on deploy notifications.&lt;/strong&gt;&lt;br&gt;
Vercel → Project Settings → Git → Deploy Notifications. Connect Slack or email — whichever you actually read. If an attacker pushes to production in your name, you want to know in minutes, not when an upstream provider flags a leaked key a week later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Scan your external perimeter.&lt;/strong&gt;&lt;br&gt;
The Vercel breach exposed secrets stored internally. A separate class of problem is secrets that are already public from your own application — &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables in your JS bundle, unauthenticated webhook endpoints, forgotten staging subdomains. Run a free external scan against your production domain at &lt;a href="https://arkensec.com/scan" rel="noopener noreferrer"&gt;arkensec.com/scan&lt;/a&gt; — 17 checks, about two minutes, no signup. It won't tell you whether Context.ai's attacker touched your specific keys, but it will tell you what's reachable from your perimeter right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the next one
&lt;/h2&gt;

&lt;p&gt;The same attack class — OAuth grant to a third-party SaaS with over-broad scopes, infostealer hits one of their employees, attacker pivots through the token, customer blast radius widens — will keep landing until platform defaults stop treating "Allow All" as a reasonable user choice.&lt;/p&gt;

&lt;p&gt;The next breach in this shape won't be Vercel. It'll be whatever agentic AI tool half your engineering team connected to Workspace last quarter. Scattered Spider ran a version of this playbook against Okta in 2023. The tooling got cheaper and the AI SaaS attack surface got much larger. The math isn't complicated.&lt;/p&gt;

&lt;p&gt;Some Vercel customers got upstream leaked-credential alerts from GitHub secret scanning and Stripe days before Vercel's official disclosure. The hosting platform was the last to know. That's not a knock on Vercel specifically — it's structural. Your credential providers see anomalous usage before your hosting provider sees anything. Set up those alerts if you haven't.&lt;/p&gt;

&lt;p&gt;I got one thing wrong in my initial read of this incident: I assumed the 22-month gap meant the attacker was being careful about access patterns to avoid detection. The more likely explanation is simpler — nobody was looking at the audit log. &lt;code&gt;env.read&lt;/code&gt; events from an unfamiliar IP in a control plane most teams never open. That's not sophisticated evasion. That's just a blind spot.&lt;/p&gt;

&lt;p&gt;Run the audit log query above. Check your Workspace OAuth grants. Rotate the credentials. Two hours of work is cheaper than finding out about your exposure through someone else's incident report.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
