Last week I wrote about surviving the AI vulnerability thunderdome after I turned Leaf’s autonomous Improvement loop loose on BrandCast and watched it file over 400 cyclomatic complexity issues in twelve hours.

Cleaning up tangled control flow helps, because bugs love to hide in deeply nested error paths. Still, a complexity reducer isn’t a security scanner.

When I pulled version control, issues, CI/CD, and repo wikis locally into Leaf after blowing through my monthly GitHub Actions minutes in 2 weeks, my security posture was uneven. On GitHub, I had relied on Trivy and Semgrep workflows. During the migration home, I ported a single daily 0 9 * * * Trivy job into BrandCast’s .factory.json and left the rest of my repositories with no automated scanning at all.

Worse, that scheduled job was wired straight into CI remediation. If Trivy exited non-zero at 9:00 AM, the factory opened a CI fix room and sent an agent to make the scanner happy. Nobody checked first whether the finding was reachable in production, sitting in a discarded Docker build stage, or just a test fixture.

This weekend, I retired that cron job and built a dedicated Security loop directly into Leaf. Then I pointed it at BrandCast to see what it would find, what it would break, and how the process would hold up.

What BrandCast actually has to defend

Before looking at the scanner output, it helps to understand what BrandCast does and where it’s exposed.

BrandCast is a multi-tenant B2B digital signage platform for small businesses. Store owners log into an admin web app to build schedules, extract brand kits, connect third-party integrations, and work with our AI assistant. Twig. Behind the scenes, a Node and Express backend coordinates background workers that render SVG scenes, convert PDFs, strip image backgrounds, and encode video tickers. Those scenes get pushed out to customer stores, where our display web app renders them on screens running inside brandcast-sentinel, our Android kiosk wrapper, as well as in browsers. It started as a passion project and I’m trying to bring it to life while keeping a day job.

Even though BrandCast is still in the Market Feedback phase, its architecture touches four real-world attack surfaces:

  1. Untrusted third-party embeds on unattended store screens. Customers can drop WEB_PAGE URLs and third-party SOCIAL_EMBED widgets (Curator, Juicer, Taggbox, Walls.io, EmbedSocial) onto their screens. Display devices hold long-lived credentials (displaySessionToken and deviceRefreshToken in localStorage) and run inside an Android WebView that exposes a privileged window.Android native bridge for device management (reading tokens, rebooting, changing Wi-Fi, or running a factory reset). If a third-party widget escapes into the display’s origin or reaches that native bridge, it owns the physical screen.
  2. Untrusted media and document parsing in backend workers. Customers upload PDFs, images , and video assets. If a native binary parser hits a memory corruption bug or a worker pulls an unpinned model over the network at runtime, a container running as root with a writable filesystem turns a media glitch into cluster compromise.
  3. Server-Side Request Forgery (SSRF) on external fetches. The backend fetches customer-supplied URLs to check iframe headers (X-Frame-Options and Content-Security-Policy), scrape brand colors, sync calendar feeds, and run MCP tools.
  4. Multi-tenant OAuth and billing credentials. The platform stores OAuth tokens for Google, Asana, Square, and Shopify encrypted at rest with AES-GCM alongside Stripe billing state.

The nuance: scanners don’t read context

Why not just run Trivy and Semgrep as a blocking pre-merge gate on every commit and let the agents fix whatever turns red?

Static rules match syntax.
Attackers exploit context.

Traditional SAST and misconfiguration scanners are built to be loud. They pattern-match a single file at a time without understanding how your application is wired together. In a human workflow, a senior engineer glances at a Semgrep warning on a test file, mutters under their breath, and clicks “Dismiss as test fixture.”

In an unattended Level 5 dark factory, raw scanner output is a denial-of-service attack on your own engineering agents. If you feed unvalidated scanner warnings directly into an autonomous coding loop, workers will waste millions of tokens refactoring unit test vectors, adding defensive wrappers around already-sanitized DOM strings, or breaking Kustomize patches that look incomplete only because Trivy read the patch file without its base manifest.

In Part 1 of the Loops series, I wrote about keeping deterministic mechanics separate from open-ended judgement. Security scanning needs both, in strict order: deterministic scanners to find every candidate cheap, and an adversarial agent to check the code before a single bug is filed.

How the security loop works

I implemented the new loop in two layers inside Leaf, using the same core tools we used to run on GitHub:

  • Trivy (trivy fs --scanners vuln,secret,misconfig): Scans the repository checkout for package vulnerabilities, leaked secrets, and Dockerfile or Kubernetes misconfigurations. For dependency CVEs, we keep only HIGH and CRITICAL findings that actually have a FixedVersion. The vulnerability database is shared across all repositories and refreshed at most once an hour.
  • Semgrep (semgrep scan): Pinned to Semgrep 1.183.0 and a single commit of semgrep-rules across 28 language and rule-pack directories, keeping only ERROR severity matches.

1. Only scan when the answer can change

Running full filesystem scans on every supervisor tick would melt my factory floor for no reason. Instead, Leaf computes a four-part scan key for each repository:

const key = { sha, trivyDb, semgrep: io.semgrep, ruleset: io.ruleset };
if (
  previous &&
  previous.sha === sha &&
  previous.trivyDb === trivyDb &&
  previous.semgrep === key.semgrep &&
  previous.ruleset === key.ruleset
) {
  return previous;
}

If HEAD hasn’t moved, the hourly Trivy database timestamp hasn’t changed, and the pinned Semgrep binary and ruleset commit are identical, the scanner doesn’t run. That phase uses zero LLM tokens and takes no agent pod seats.

Each finding is keyed by a line-independent fingerprint ([scanner, rule, package, path]). When a file gets edited and a match shifts down twenty lines, its fingerprint stays the same.

2. Validate with an examiner before filing

When a scan turns up fingerprints that don’t have a stored verdict, Leaf opens a validation room and spawns an Examiner agent at Class 4 (prompts/security.md) on a detached origin/main worktree.

The Examiner isn’t allowed to edit code or write tests. Its prompt gives it one rule above all else: the scanner is not evidence.

  • For a package vulnerability, it must read package-lock.json (not the range in package.json) and verify that shipped code actually calls the vulnerable function or option.
  • For a secret, it must trace whether the string is a live credential or a test vector, placeholder, or JSDoc example.
  • For a Semgrep match, it must trace the source to see if attacker-controlled input can actually reach the sink unvalidated.
  • For a Kubernetes misconfiguration, it cannot judge a YAML file in isolation. It has to read the Kustomize kustomization.yaml, its base manifests, and its overlays together to judge the effective rendered workload.

Every fingerprint gets a permanent real or false-positive verdict in SQLite with a cited code reason. Stored false positives never wake an agent up again unless I reverse them in the repository Settings tab. Confirmed real findings get grouped into bug issues and stamped into the security loop.

289 findings, and how the loop broke on day one

When I flipped BrandCast’s security setting to on, the initial scans evaluated 289 distinct fingerprints across the monorepo: 139 false-positive verdicts and 150 real verdicts.

Those numbers look tidy in a summary table now, but our first pass on Saturday morning immediately exposed two design flaws in the loop itself:

  1. Grouping by file and rule created a ticket storm. In my first implementation, Leaf grouped new findings mechanically: vulnerabilities by package, Semgrep matches by rule ID, and misconfigurations by file path, spawning a separate Examiner for each group. Because Trivy evaluates every Kubernetes YAML file in isolation, it flagged the same missing securityContext rules across our base manifests and every dev, staging, and prod Kustomize patch. Meanwhile, two overlapping Semgrep DOM rules matched the exact same line in our social embed component. Result: the factory filed 16 separate security bugs that would have sent 16 separate plans to edit pieces of the same fix.
  2. Line deduplication hid a real Dockerfile bug behind a harmless one. Because our fingerprint intentionally strips line numbers (["trivy", "DS-0029", "", "apps/backend/Dockerfile"]), my initial parser deduplicated multiple hits in the same file by keeping only the lowest line number. In a multi-stage Dockerfile, line 10 is the deps build stage (which never ships, so an apt-get rule there is a false positive) while line 75 is the shipped runner stage (where the exact same rule is real). Keeping only the first line blinded the Examiner to the shipped stage.

We fixed both in Leaf before touching BrandCast. First, every fingerprint now carries an occurrences: { line, evidence }[] array so the Examiner sees every line in the file. Second, instead of pre-slicing findings by file or rule, Leaf now hands the entire scan’s new findings to a single Examiner and requires it to group real fingerprints by root cause, where one cause equals one coherent architectural change across all affected files.

What the examiner threw out (and what was real)

Watching the Examiner work through the 289 fingerprints was the best validation of the two-stage design.

Scanner & CategoryFalse PositivesReal FindingsOutcome in BrandCast
Trivy (vuln CVEs)00Zero HIGH/CRITICAL fixable CVEs in the lockfile.
Semgrep (ERROR rules)11211 dismissed with code citations; 2 DOM XSS sinks fixed by iframe sandboxing.
Trivy (DS-* & KSV-*)128148128 dismissed (unreferenced YAML, Kustomize patches, flag names); 148 fixed via 4 infra changes.

The 139 false positives that didn’t become tickets

Without the Examiner, 139 phantom problems would have landed in the backlog. A few examples from the security_verdict table show why static rules need a code-reading counterweight:

  • False secret alarms on feature flags and docs: Trivy rule KSV-01010 flagged our feature-flags-configmap.yaml across dev, staging, and prod for storing sensitive data in a ConfigMap. The Examiner read line 11, saw FF_EMAIL_SUMMARY: "false", checked packages/core/src/config/feature-flags.ts, and marked it false-positive because the scanner simply matched the substring EMAIL in a boolean toggle name. Semgrep similarly flagged truncated ya29.a0Af... Google OAuth example strings inside JSDoc @example blocks and an RFC 6238 SHA-1 test vector in totp.test.ts.
  • False SQLi, SSRF, and crypto alarms: Semgrep flagged tainted-sql-string on two route handlers where template literals were actually being passed to structured logger metadata and an HTTP 409 JSON error message, not a database query. It flagged mcp-ssrf-typescript on a line that only called JSON.stringify() on an MCP tool response. And it flagged gcm-no-tag-length on tokenEncryption.ts, where the Examiner verified that lines 176 to 180 already enforce a strict 16-byte AUTH_TAG_LENGTH check before createDecipheriv is ever called.
  • Sanitized DOM parsing: Semgrep flagged sanitizeHelpHtml.ts for assigning to innerHTML on a detached <div>. The Examiner traced the input and confirmed it only receives the output of DOMPurify.sanitize(html, HELP_HTML_SANITIZE_CONFIG) so the helper can strip <iframe> tags whose hostname isn’t on our allowlist.

The 150 real findings became 5 structural fixes

Once the noise was cleared out and the 16 fragmented per-file issues were superseded, the 150 real findings boiled down to five engineering changes across BrandCast:

1. Sandboxing third-party social embeds (and catching a deeper Android bridge bug)

The two real Semgrep findings were on line 116 of SocialEmbedDisplay.tsx. When a customer configured a JavaScript social media wall (like Curator or Juicer), the display app set container.innerHTML = embedData.embedCode and appended the vendor’s <script> tags directly into the display app’s own document.

When we dug into the Examiner’s trace, the hole was worse than a single innerHTML sink:

  • Because the vendor script ran in the display app’s top document, it shared the display’s origin and could read displaySessionToken and deviceRefreshToken straight out of localStorage.
  • Our EmbedCodeValidator regex only checked for quoted inline event handlers, so <img src=x onerror=alert(1)> sailed right through validation.
  • A custom platform option allowed scripts from any HTTPS domain.
  • When validation failed on the backend, the SOCIAL_EMBED renderer set embedError on the payload but still returned the raw embedCode and scriptSrc to the display.

We fixed the root cause instead of patching the regex. First, we deleted the custom arbitrary-host option and stripped embedCode and scriptSrc from failed validation responses. Second, we moved JavaScript social embeds into an isolated <iframe> using sandbox="allow-scripts" and srcDoc, shared between the display player and the admin preview:

<iframe
  ref={iframeRef}
  title={`${embedData.service} embed`}
  sandbox="allow-scripts"
  srcDoc={buildSocialEmbedSrcDoc(embedData.embedCode, embedData.scriptSrc)}
  className="w-full h-full border-0"
/>

Because sandbox="allow-scripts" omits allow-same-origin, the browser gives the iframe a unique opaque origin. Vendor scripts can still render their feed inside the frame, but they can never touch the parent DOM or read localStorage.

Investigating that fix also uncovered a second vulnerability that neither scanner knew how to look for. On our physical Android signage players (brandcast-sentinel), the app exposes device controls via Android’s WebView.addJavascriptInterface(webAppInterface, "Android"). Android injects @JavascriptInterface objects into every frame in the WebView, including cross-origin and sandboxed iframes. That meant a third-party WEB_PAGE or social iframe on a physical player could still call window.Android.getToken() or window.Android.factoryReset(). We immediately filed an issue on brandcast-sentinel to lock the native bridge down to the top frame on ALLOWED_HOSTS.

2. Cleaning up dead K8s manifests and Kustomize drift

Before fixing the 146 Kubernetes (KSV-*) findings, we noticed why there were so many duplicates: ops/k8s was carrying dead weight and copy-pasted files.

Several manifests in ops/k8s/base (redis.yaml, gotenberg.yaml, pdf-converter.yaml) weren’t even deployed by BrandCast’s CD pipeline anymore because they had moved to Fleet, and three files (migration-job.yaml, backend-secrets-mount-patch.yaml, and ai-config-configmap.yaml) were byte-for-byte duplicated across dev, staging, and prod. Worse, deploy-backend.sh was creating the database migration Job with raw kubectl create -f against the overlay file, completely bypassing Kustomize.

We deleted the dead manifests, moved the shared config files out of the per-environment overlays so each overlay only holds values that actually differ by environment, and routed the migration Job through Kustomize.

3. Enforcing Kubernetes workload security defaults as a deploy gate

With Kustomize cleaned up, we didn’t want to hand-edit 14 individual YAML files to satisfy Trivy’s container hardening rules (KSV-0001, KSV-0003, KSV-0014, KSV-0020, KSV-0021, KSV-0030, KSV-0104, KSV-0106).

Instead, we created a single Kustomize component (ops/k8s/components/security-defaults) applied across ops/k8s/base, the migration Job, and ops/k8s/ticker-generator/base. Every Deployment, Job, and CronJob now inherits Kubernetes security context defaults automatically:

  • runAsNonRoot: true with high UID/GID (> 10000)
  • allowPrivilegeEscalation: false
  • capabilities.drop: ["ALL"]
  • readOnlyRootFilesystem: true (with emptyDir mounts for /tmp and runtime scratch paths)
  • seccompProfile.type: RuntimeDefault
  • Explicit CPU and memory requests and limits on the migration Job and seed CronJob

To make sure no future workload slips through, we added a check script to deploy-backend.sh, deploy-ticker.sh, and npm run lint:manifests that inspects the rendered Kustomize output and aborts the deploy if any workload is missing those defaults.

4. Giving ticker-generator a real liveness probe and slimming images

Trivy flagged apps/ticker-generator/Dockerfile and apps/backend/Dockerfile for installing recommended apt packages in their shipped runner stages (DS-0029) and flagged ticker-generator for lacking a healthcheck (DS-0026).

Adding a Docker HEALTHCHECK instruction would have silenced Trivy, but it wouldn’t have helped production because Kubernetes ignores Docker HEALTHCHECK instructions. And since ticker-generator is a queue worker with no HTTP server, Kubernetes couldn’t use an HTTP probe either.

Instead, we wired the BullMQ worker to update a heartbeat file on an emptyDir mount while its worker loop is healthy, added a Kubernetes exec liveness probe that restarts the pod if the heartbeat goes stale, and added --no-install-recommends to every shipped apt-get install.

5. Pulling pdf-converter and bg-remover into the monorepo with offline models

Deleting the stale ops/k8s/base/pdf-converter.yaml manifest forced a bigger question: why were pdf-converter and bg-remover still sitting in separate standalone repositories outside Factory’s build, test, and security loops?

When we inspected bg-remover, we found that it started up with env.allowLocalModels = false and downloaded briaai/RMBG-1.4 from HuggingFace at runtime with no pinned commit revision into a writable /app/node_modules/@huggingface/transformers/.cache directory. That broke both reproducibility and readOnlyRootFilesystem.

We ported both services into apps/pdf-converter and apps/bg-remover inside the BrandCast monorepo, baked the briaai/RMBG-1.4 ONNX model into the image at a single pinned commit hash with env.allowRemoteModels = false (verified in CI by running a background-removal check inside a --network=none container), and added scripts/image-version.sh using skopeo so that Factory’s primary image tag hashes both the source inputs and the upstream base image digest. Whenever an upstream Node base image ships a security fix, all four BrandCast images rebuild and promote automatically on the next run.

What’s breaking right now on the server

If I ended the post here, it would sound like a victory lap. In reality, inspecting our Factory instance on the apps server tonight shows three places where the process is still rough around the edges:

  1. Trivy is choking on 72GB of untracked agent trajectories in factory itself. After finishing the initial BrandCast sweep, I flipped the Security loop on for the factory repository. On our apps server, factory’s checkout directory (/home/jduncan/Code/factory) also happens to be its runtime data directory, which holds 1.2GB of SQLite state, 234MB of release artifacts, and 72GB of agent run logs under trajectories/. Because trivy fs walks untracked directories by default, it has been spinning up 4+ CPU cores (436% CPU) trying to scan tens of thousands of JSON trajectory files until it crashes with semaphore acquire: context deadline exceeded. Even on BrandCast, Trivy wasted time scanning an untracked Dockerfile inside node_modules/yaml-language-server. Our immediate next fix in src/security.scan.ts is passing --skip-dirs (or scanning a clean worktree) so neither scanner touches trajectories/, releases/, node_modules/, or .git/.
  2. A strict JSON parser dropped a valid false-positive turn. On the most recent BrandCast scan, 219 of 220 findings already had verdicts, leaving just one new low-severity finding (KSV-0110 on staging/replicas-patch.yaml). The Examiner inspected staging/kustomization.yaml, confirmed it sets namespace: brandcast, and wrote a thorough false-positive explanation. However, because there were zero real bugs in the batch, the model omitted "bugs": [] from its JSON reply and formatted fingerprint as a JSON array instead of a stringified tuple. parsedAnswer() rejected the entire payload and threw the turn away. Making parsedAnswer() normalize array fingerprints and default an omitted bugs key to [] when every verdict is false-positive will stop us from burning Class 4 turns on formatting pedantry.
  3. Turning the loop back on to verify the post-fix baseline. We paused BrandCast’s security toggle while landing the Kustomize security-defaults component and the monorepo service ports. Once the --skip-dirs and parser fixes land in Factory, we’ll flip BrandCast back to on (to let the Examiner verify the new baseline and record base-mitigated verdicts on the remaining Kustomize overlay patches), land the Android WebView bridge fix on brandcast-sentinel, and roll the loop out across the rest of my repositories.

So what?

If you’re wiring security scanning into an agentic SDLC, a few lessons from our first weekend with this loop stand out:

  • Never pipe raw scanner output straight to a coding agent. Use deterministic tools like Trivy and Semgrep to find candidates cheaply, then put a read-only Examiner agent in front of your backlog to verify reachability, lockfiles, and rendered manifests before filing a bug.
  • Give the Examiner the whole scan, and group by root cause. Slicing findings by file or rule ID guarantees fragmented tickets and drift across Kustomize overlays or duplicated templates. One root cause should produce one issue.
  • Deduplicate by fingerprint, not by first line. If you strip line numbers so findings survive code movement, keep an array of every line occurrence in the file so a harmless build-stage match at line 10 doesn’t hide a real production match at line 75.
  • Use scanner noise as an architectural smell. When a scanner flags 140 misconfigurations across your deployment manifests, the right move isn’t editing 14 YAML files. Delete the dead manifests, pull shared settings into one Kustomize component, and make those security defaults a hard pre-deploy check.