There are three valid adopter paths. Pick yours first, then follow the matching steps. Most local-setup friction comes from running the wrong path's instructions on a machine that doesn't support them.
| Persona | Local Azure auth needed? | What runs locally | What runs in CI |
|---|---|---|---|
| Author-only (recommended for restricted devices) | None | clone, edit YAML under detections/, python -m contentops lint, python -m contentops new, python -m contentops doctor (L1 install check only) |
Every Azure call: apply, plan, conformance L3+, drift detection |
Local-test (you have az login permissions) |
az login as your user |
Everything above plus plan --dry-run, conformance end-to-end |
Real apply (still in CI for audit-trail integrity) |
| CI-mirror (you want to reproduce CI exactly) | .env with App Registration client secret |
Everything above plus real apply |
Nothing — local matches CI exactly |
Security engineers on locked-down workstations, contractors without
tenant access, and most content authors should pick Author-only.
You can ship rules without ever running az login on your laptop —
GitHub Actions does every Azure call via OIDC federation. The rest of
this guide is structured so you can stop at Step 4 (lint + push) if
that's your path.
- Python 3.12+
git- A clone of either the public mirror
(
https://github.com/KustoKing/ContentOps) or your private fork. - For Local-test persona:
azCLI (az loginaccess to the target tenant; your user needsMicrosoft Sentinel Contributoron the workspace's resource group). - For CI-mirror persona: An Azure App Registration with OIDC
federated credentials (and Sentinel RBAC on its service principal).
See
docs/operations/authentication-setup.md.
git clone https://github.com/KustoKing/ContentOps.git
cd ContentOps
python -m venv .venvActivate the venv (per platform):
# Windows PowerShell:
.\.venv\Scripts\Activate.ps1
# Windows cmd.exe:
.venv\Scripts\activate.bat
# macOS / Linux (bash / zsh):
source .venv/bin/activateIf you get an execution-policy error on activation:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserThen install:
python -m pip install -r requirements.txt
python -m pip install -e .
python -m contentops --versionWe use python -m pip (not bare pip) and python -m contentops
(not bare contentops) deliberately. See "Locked-down corporate
Windows" below for why — short version: corporate endpoint security
(Device Guard / AV) sometimes blocks pip-installed .exe shims but
always allows invocation through Python.
You don't need Azure credentials. Skip to step 3 and ignore steps 4–7.
az login
az account show # verify it landed on the right tenant + subscriptionThen copy the tenant config template:
copy config\tenant.yml.example config\tenant.yml
# Edit config/tenant.yml — replace the placeholder GUIDs and names
# with your subscription, resource group, workspace name, location.Your .env stays empty or unused — DefaultAzureCredential falls
through to AzureCliCredential which picks up your az login token.
copy .env.example .env
copy config\tenant.yml.example config\tenant.ymlFill .env with AZURE_TENANT_ID, AZURE_CLIENT_ID, and
AZURE_CLIENT_SECRET. Fill config/tenant.yml with subscription /
RG / workspace details. Both files are gitignored.
Local-only sanity check, no Azure call:
python -m contentops doctorL1 checks: Python version, package import, envelope parse, git. All adopter personas should see green here.
For Author-only persona, you're done with setup. Skip to Author your first detection below.
For Local-test and CI-mirror, run the layered diagnostic:
python -m contentops doctor --matrix
python -m contentops conformanceThe matrix exercises real Azure reads. conformance runs L1–L7 in
order with explicit remediation hints on failure.
When doctor --matrix or conformance shows an auth failure on a
Sentinel or Graph endpoint, the HTTP status code matters:
| Status | Meaning | Fix |
|---|---|---|
| 401 Unauthorized | The token itself was rejected. Wrong tenant context, expired, or DefaultAzureCredential returned a stale cached identity (SharedTokenCache / VSCode) instead of the one az login minted. |
First try $env:AZURE_TOKEN_CREDENTIALS = "dev" to force the dev-credential chain. If that fixes it, you hit a known credential-chain ordering issue on multi-identity machines. Then verify az account show matches tenant.yml. |
| 403 Forbidden | Authenticated successfully, but the identity lacks RBAC on this resource. | Grant Microsoft Sentinel Contributor on the workspace's resource group to whichever identity is active — your user on Path A, the App Reg on Path B. RBAC propagation can take 5–15 minutes. |
| 500 / other 5xx | Service-side issue, often transient. | Retry; if persistent, check Azure Service Health. |
Scaffold:
python -m contentops new sentinel_analytic my-first-ruleEdit detections/sentinel_analytic/my-first-rule.yml. The scaffold
emits TODO (METAxxx): ... placeholders for required authoring
metadata. Replace them as you fill in the rule. Until you move
status past experimental, the lint stays gentle (warnings only,
no CI block).
Minimum content to make a real rule:
- Non-empty
descriptionandattackDescription(META002/003). - At least one entry in
tacticsandtechniques(PAYLOAD003). - A real
query:block underpayload. - An
owneremail andrunbookUrl.
First time? A full walkthrough — from "what am I detecting?" through to "promoted to production" — lives at
docs/tutorials/your-first-detection.md. Use it instead of this section if you want a copy-pasteable example with a real KQL query, MITRE tags, and the lifecycle promote flow.
Works on all adopter personas — no Azure auth required:
python -m contentops lint --strictlint --strict is what CI runs; fixing locally means the PR is green
from the first push.
Heads up — META002–005 default since PR #241. A fresh
config/tenant.ymlruns the META authoring rules in lenient mode (warnings, not CI-blocking errors). This matches the operational reality that the G24 authoring backlog still exists on collected envelopes — adopters shouldn't get blocked the moment they configure tenant.yml. Setpolicy.scaffoldStrict: trueexplicitly once your team has drained the authoring backlog and wants META002–005 to gate CI.
Static plan — no API calls:
python -m contentops planFor a live preview that shows what apply will actually do
against the tenant (CREATE / UPDATE / NO-CHANGE / ORPHAN-IN-TENANT),
add --against-tenant:
python -m contentops plan --against-tenant --role integrationRead-only — shows what would change in the tenant, no writes. The
overlay is fail-soft so fork PRs / offline runs still work; the
counts come back as - when OIDC isn't available.
Author-only adopters: open a PR. CI applies on merge to main.
Local-test / CI-mirror adopters can dry-run:
python -m contentops apply --dry-runThen real apply:
python -m contentops applyThe 2026-05-22 sprint cluster added several read-only commands that help you understand the tenant's coverage and health. None of these mutate state.
| Command | What it shows |
|---|---|
python -m contentops navigator --since 365 --out tmp.json |
A MITRE ATT&CK Navigator layer JSON aggregating three axes (repo envelopes + deployed rules + live alert firings). Upload to https://mitre-attack.github.io/attack-navigator/ to visualise. |
python -m contentops coverage --gaps |
Inverse heatmap — which ATT&CK techniques you DON'T cover. |
python -m contentops coverage --d3fend |
MITRE D3FEND defensive-axis coverage report (companion to ATT&CK). Reads metadata.defensiveTechniques: [D3-XXX]. |
python -m contentops silent-rules --since 30 |
Rules that fired zero alerts in the lookback window. |
python -m contentops auto-disabled-rules --since 7 |
Rules Sentinel itself disabled (consecutive failures, schema break). Distinct from silent rules. Requires the SentinelHealth diagnostic to be enabled on the workspace. |
python -m contentops portfolio --with-telemetry --out-csv portfolio.csv |
Flat per-detection report with alerts_30d, incidents_30d, closed_fp_30d, fp_rate. |
python -m contentops detection-docs regenerate |
Renders every envelope to docs/detections/<asset>/<id>.md — browsable per-rule documentation. |
If a step fails, the error message points at the fix — and every
real error you might hit is indexed in
docs/troubleshooting.md.
Many corporate Windows machines run Device Guard / Microsoft Defender Application Control (WDAC) and/or endpoint AV that blocks unsigned executables, throttles parallel subprocess invocations, and sometimes blocks specific Rust extension DLLs. The full adopter test we ran in May 2026 hit every one of these. Here's what you'll see and how to work through it.
Symptom:
ImportError: DLL load failed while importing _rust:
Your organization used Device Guard to block this app.
Cause: azure-identity depends on cryptography, which ships a
Rust-backed extension (_rust.cp312-win_amd64.pyd). The DLL inside
your venv's site-packages isn't on Device Guard's path allowlist
even though the same DLL elsewhere is allowed.
Fix options:
- Use system Python (no venv) — IT pre-approved system Python's
cryptographyinstall. Simplest path:deactivate, runpython -m pip install -e .against system Python, retry. - Ask IT to allowlist
%USERPROFILE%\.venv\Lib\site-packages\cryptography\hazmat\bindings\_rust*.pydpaths. - Use conda / mamba instead of pip — different binary layout that some Device Guard policies allow.
Bare console scripts created by pip install are pre-approved
executables. Custom shims (pip.exe inside a venv, contentops.exe
from our package) sometimes aren't. Invocation through the Python
interpreter — python -m pip, python -m contentops — bypasses the
shim entirely because Python itself is always allowlisted.
This guide uses the python -m form throughout for that reason.
When collecting from the tenant, each handler authenticates via
AzureCliCredential, which spawns az.cmd as a subprocess. The
default of 4 parallel workers spawns 4 simultaneous az.cmd
invocations on cold start. Some Device Guard / AV configurations
throttle this and the credential fails with "Failed to invoke the
Azure CLI."
Fix: drop to single-worker for the affected machines:
python -m contentops collect --workers 1The first token acquisition is slower but the token then caches and subsequent handlers reuse it.
Gitleaks 8.21+ switched to a model where organizational use requires a (free) license key from https://gitleaks.io/. The check looks at the repo's owner; org-owned repos trigger the prompt.
Recommended path (lets you proceed today): skip
pre-commit install locally for now. The pre-commit gitleaks hook is
optional; CI's secret-scan.yml workflow runs gitleaks regardless,
and you can deal with the license when wiring CI. Get the license in
parallel — it usually arrives within hours.
Alternative: if your org has GitHub Advanced Security, native
secret scanning covers the same ground and you can remove
secret-scan.yml from your fork.
If your private fork is in a GitHub Enterprise Cloud organization
with SAML SSO, git push and gh repo view will return 404 Not
Found (not 403) when your credential exists but isn't authorized
for the org. The 404 looks like the repo doesn't exist — but it does.
Fix:
gh auth login --hostname github.com --web --scopes "repo,workflow,read:org"Then complete the device-code flow in the browser. If your org uses a PAT directly, open https://github.com/settings/tokens, find the PAT, click Configure SSO next to it, Authorize the org.
The public mirror at https://github.com/KustoKing/ContentOps is
rebuilt nightly from the private operator repo. If you cloned recently
and the code looks older than expected (CLI banner says pipeline
rather than contentops, or you see asset kinds beyond the canonical
six listed in CLAUDE.md), the mirror just hadn't synced yet. Pull
fresh:
git fetch upstream
git pull upstream main(Assumes you set up upstream as the public mirror remote per the
"Import the code" section above.)
Before opening a PR on a fresh fork, you need to configure GitHub Actions Variables, Secrets, Environments, and federated credentials on the App Registration. Without these, CI fails at the first Azure-touching step.
The full setup is in
operations/github-actions-setup.md
— it covers every secret/variable/environment with the exact gh
commands and federated-credential subjects. Do this once per fork
before your first push.
Short version of what you'll wire:
| Type | Name | When required |
|---|---|---|
| Variable | AZURE_CLIENT_ID |
Always |
| Variable | AZURE_TENANT_ID |
Always |
| Secret | TENANT_CONFIG_YAML |
Always (gh secret set stdin form) |
| Secret | GITLEAKS_LICENSE |
When org-owned + license email arrives |
| Environment | production |
For deploy + live tests |
| Environment | integration |
If you have a second workspace |
| Environment | automation |
For cron workflows (drift, silent-rules, defender-graph-probe) |
| Federated credential | one per environment | OIDC subject repo:<org>/<repo>:environment:<env> |
- First real rule, end-to-end:
docs/tutorials/your-first-detection.mdwalks the full lifecycle (author → lint → plan → apply → verify → promote) with a worked example. - Day-to-day operations:
docs/OPERATOR_GUIDE.mdcovers the daily flow, drift PR handling, and the when-things-break decision tree. - Hit an error?
docs/troubleshooting.mdindexes every real error we've encountered (Auth, Config, Lint, Apply, Drift, CI gates, Fork-PR limits, Workspace data) with the exact fix for each. - Unfamiliar term?
docs/glossary.mddefines the recurring jargon (envelope, drift PR, scaffoldStrict, D3FEND, Navigator layer, env-status gate, etc.). - Open a PR with
detections/sentinel_analytic/my-first-rule.yml. CI runslint --strict, integration-deploy on the integration workspace (if configured), gitleaks, codespell, references URL check, and the other gates;deploy.ymlships to prod on merge.