When a Jamf Pro tenant starts to mature, extension attributes pile up fast. Some of them are doing real work. Some of them were built for a project six months ago and never looked at again. Some of them still matter, but only in one narrow place that nobody remembers until something breaks. The problem is not that the list gets long. The problem is that once the list gets long, it becomes hard to tell which attributes are actually tied into live Smart Group logic and which ones are just sitting there.
That is the problem I wanted to solve with this script.
Jamf is clear about what extension attributes are for. They let you store additional inventory information beyond what Jamf collects by default, and they can be used for reporting, grouping, and even initiating tasks on managed devices. Jamf also documents that Smart Groups can use extension-attribute criteria and that group membership is recalculated when inventory changes come in. That means extension attributes are not just extra metadata. They can sit directly in the path of scoping and automation. Extension Attributes Acting on Extension Attribute Data
What I wanted was a report, not a cleanup tool. I did not want a script that archived anything, renamed anything, or made recommendations that sounded more confident than the data deserved. I wanted something read-only that would tell me, in a few seconds, which extension attributes were actually being referenced by Smart Computer Groups and how widely they were being used.
That is what this script does.
What the script actually checks
The script authenticates to Jamf Pro, reads the computer extension attribute list, reads the computer group list, filters down to Smart Computer Groups, then walks each group’s criteria and counts where an extension attribute name shows up. From there it ranks the results by breadth of Smart Group usage, prints a terminal table, and can also write JSON and HTML output.
The key detail is that the ranking is based on actual Smart Group references, not just the fact that an extension attribute exists. That distinction matters. A tenant can have fifty extension attributes and only a handful of them may be doing most of the work in dynamic scoping. That is useful to know before you touch names, values, scripts, or related reporting.
I also wanted the output to be easy to hand off. A terminal table is fine for quick work, but JSON is useful if you want to feed another process, and HTML is useful if you want to drop the report into a ticket, review it with another admin, or keep an artifact from a point in time.
The exact command I used to validate it
For the script itself, this is the exact command format to use:
export JAMF_URL="https://yourtenant.jamfcloud.com"
export JAMF_CLIENT_ID="your_client_id"
export JAMF_CLIENT_SECRET="your_client_secret"
python3 jamf-extension-attribute-usage-report.py \
--top 15 \
--json-out ea-usage.json \
--html-out ea-usage.html
If you want to validate the parser and report logic without hitting Jamf at all, use:
python3 jamf-extension-attribute-usage-report.py --self-test
There is also a username and password fallback path in the script, but I prefer client credentials for this kind of reporting. Jamf supports client credentials for API access, and that is the path I would use for a dedicated reporting integration. Client Credentials Jamf Pro API Overview
How to create the API role and API client
This part matters because the role and the client are separate objects in Jamf. The API role is where the privileges live. The API client is the thing that gets assigned the role and gives you the Client ID and Client Secret.
I would create a dedicated read-only role and client for this script instead of reusing something broader.
Step 1: Create the API role
In Jamf Pro, go to the API Roles and Clients area and create a new role. I would name it something obvious like ea-usage-report-readonly.
For this script as written, the minimum read privileges are:
Read Computer Extension AttributesRead Smart Computer GroupsRead Static Computer Groups
That third privilege is easy to miss. Even though the report only ranks Smart Group usage, the script first reads the broader computer-groups collection and then filters down to smart groups in code. If the client cannot read the group collection cleanly, the report is going to fail or return incomplete data.
The script does not create, update, or delete anything, so there is no reason to grant write privileges here.
Step 2: Create the API client
Once the role exists, create a separate API client and assign that read-only role to it. I would name the client something equally obvious like ea-usage-report-client.
When you save the client, Jamf will give you the Client ID and a Client Secret. Copy both right away and store them somewhere safe. The secret is what you export as JAMF_CLIENT_SECRET before you run the script.
One Jamf detail that is worth calling out here: if you change the role assignment later, rotate the client secret after the role change. Jamf documents that role changes on an existing API client do not take effect until the secret is rotated. That is one of the first things I would check if the client looks right on paper but still fails in practice. Client Credentials
If your client credentials still fail after that, test the same script with a known-good username/password API account. If username/password works and client credentials do not, the problem is usually one of two things: the API role is still missing one of the read privileges above, or the client secret was never rotated after a role change.
A real sample from a validated run
Here is a sanitized sample from a real tenant run:
Rank EA Name Smart Groups Criteria Hits Coverage
---- ------------------------------ ------------ ------------- --------
1 US CMMC 2.0 Level 2 (Enforce) 8 8 6.25%
2 Compliance - Version 5 5 3.91%
3 Adobe Updates 3 3 2.34%
4 Default Browser 2 2 1.56%
5 CMMC - pwpolicy 35-Day Inactiv 2 2 1.56%
6 CMMC - FMSecure 2 2 1.56%
7 Automated Enrollment Workflow 2 2 1.56%
8 Account Status 2 2 1.56%
9 Requres PW Change 1 1 0.78%
10 Mac App Store Apps 1 1 0.78%
11 Lockout Window 1 1 0.78%
12 Jamf Trust VPN Status 1 1 0.78%
13 Jamf Protect Version 1 1 0.78%
14 Jamf Protect - Last Check-in 1 1 0.78%
15 Jamf Connect - FirstRunDone 1 1 0.78%
Total extension attributes: 53
Total smart groups scanned: 128
Wrote JSON report to ea-usage.json
Wrote HTML report to ea-usage.html
This is exactly the kind of output I wanted. I can see right away that a small number of extension attributes are carrying more of the Smart Group logic than the long tail below them. I can also see that an attribute with a count of one is not necessarily trivial. In a tenant with 128 Smart Groups, a count of one still means that attribute is part of a real scoping decision somewhere.
That is why I like this report. It does not pretend to tell me what to delete. It tells me where the dependencies are.
The part of the script that matters most
The heart of the script is not the auth block. It is the part where group criteria are matched back to extension attribute names and then counted.
ea_name_to_id = {ea.name: ea.id for ea in extension_attributes}
for group in smart_groups:
matched_ids: set[int] = set()
for criterion_name in group.criteria_names:
ea_id = ea_name_to_id.get(criterion_name)
if ea_id is None:
continue
usage[ea_id]["criteria_hits"] += 1
matched_ids.add(ea_id)
for ea_id in matched_ids:
usage[ea_id]["smart_group_count"] += 1
usage[ea_id]["smart_group_names"].append(group.name)
That gives me two different signals. criteria_hits tells me how many times an extension attribute name appeared across all the criteria I scanned. smart_group_count tells me how many unique Smart Groups referenced it. For this use case, smart_group_count is the ranking I care about most because it tells me how widely an attribute is distributed across the tenant’s grouping logic.
There is one boundary I would keep in mind. This is name-based matching. If you have an extension attribute whose name collides with a built-in inventory field, that is something I would validate manually in the output. In practice that is manageable, but it is still worth saying out loud.
Why I stayed read-only
I know there is a temptation to turn every report into a cleanup tool. I did not want to do that here.
Once a script starts moving objects, archiving things, or trying to be smart about what is “safe” to clean up, the burden of trust changes. A ranking report is easy to reason about. It reads data, correlates data, and writes output. That is a good boundary for something I might hand to another admin or run against production without a change window.
In other words, I wanted visibility first. Human decisions can come after that.
If you want the script itself, it lives here in my GitHub repo: Jamf Extension Attributes
Full script
If you want to review the script in context, grab updates, or use the repository copy instead of the embedded version below, the GitHub folder is here: Jamf Extension Attributes. I also put together a matching README there that walks through the purpose of the script, the required Jamf API role privileges, and the basic run commands.
I still like embedding the full script in the post because it lets you inspect the exact logic without jumping away first. If you are the kind of admin who wants to read the code before you run anything against production, this section is for you.
#!/usr/bin/env python3
"""
Rank Jamf Pro computer extension attributes by smart-group usage.
Read-only script:
- Authenticates to Jamf Pro with client credentials or username/password
- Reads computer extension attributes from the Classic API
- Reads smart computer groups from the Classic API
- Matches smart-group criteria names against extension-attribute names
- Prints a ranked report and can emit JSON/HTML output
This script intentionally does not modify Jamf objects.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import requests
from requests.auth import HTTPBasicAuth
class JamfApiError(RuntimeError):
"""Raised when a Jamf API operation fails."""
@dataclass
class ExtensionAttribute:
id: int
name: str
data_type: str = ""
inventory_display: str = ""
input_type: str = ""
@dataclass
class SmartGroup:
id: int
name: str
criteria_names: list[str] = field(default_factory=list)
def text_or_empty(node: ET.Element | None, xpath: str) -> str:
if node is None:
return ""
found = node.find(xpath)
if found is None or found.text is None:
return ""
return found.text.strip()
class JamfClient:
def __init__(
self,
base_url: str,
*,
client_id: str | None = None,
client_secret: str | None = None,
user: str | None = None,
password: str | None = None,
verify_tls: bool = True,
timeout: int = 30,
) -> None:
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self.user = user
self.password = password
self.verify_tls = verify_tls
self.timeout = timeout
self.session = requests.Session()
self.token: str | None = None
def authenticate(self) -> None:
if self.client_id and self.client_secret:
if self._auth_with_client_credentials():
return
if self.user and self.password:
if self._auth_with_user_password():
return
raise JamfApiError(
"Authentication failed. Provide JAMF_CLIENT_ID/JAMF_CLIENT_SECRET "
"or JAMF_USER/JAMF_PASSWORD."
)
def _auth_with_client_credentials(self) -> bool:
token_url = f"{self.base_url}/api/oauth/token"
payload = {"grant_type": "client_credentials"}
attempts = [
{
"auth": HTTPBasicAuth(self.client_id or "", self.client_secret or ""),
"data": payload,
},
{
"auth": None,
"data": {
"grant_type": "client_credentials",
"client_id": self.client_id or "",
"client_secret": self.client_secret or "",
},
},
]
for attempt in attempts:
try:
response = self.session.post(
token_url,
auth=attempt["auth"],
data=attempt["data"],
headers={"Accept": "application/json"},
timeout=self.timeout,
verify=self.verify_tls,
)
except requests.RequestException:
continue
if response.ok:
data = response.json()
access_token = data.get("access_token") or data.get("token")
if access_token:
self.token = access_token
return True
return False
def _auth_with_user_password(self) -> bool:
token_url = f"{self.base_url}/api/v1/auth/token"
try:
response = self.session.post(
token_url,
auth=HTTPBasicAuth(self.user or "", self.password or ""),
headers={"Accept": "application/json"},
timeout=self.timeout,
verify=self.verify_tls,
)
except requests.RequestException:
return False
if not response.ok:
return False
data = response.json()
access_token = data.get("token") or data.get("access_token")
if not access_token:
return False
self.token = access_token
return True
def get_classic_xml(self, path: str) -> ET.Element:
if not self.token:
raise JamfApiError("No access token available. Call authenticate() first.")
url = f"{self.base_url}{path}"
try:
response = self.session.get(
url,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/xml",
},
timeout=self.timeout,
verify=self.verify_tls,
)
response.raise_for_status()
except requests.RequestException as exc:
raise JamfApiError(f"GET failed for {path}: {exc}") from exc
try:
return ET.fromstring(response.text)
except ET.ParseError as exc:
raise JamfApiError(f"Failed to parse XML for {path}: {exc}") from exc
def fetch_extension_attributes(client: JamfClient) -> list[ExtensionAttribute]:
root = client.get_classic_xml("/JSSResource/computerextensionattributes")
results: list[ExtensionAttribute] = []
for node in root.findall("./computer_extension_attribute"):
ea_id = text_or_empty(node, "id")
name = text_or_empty(node, "name")
if not ea_id or not name:
continue
results.append(
ExtensionAttribute(
id=int(ea_id),
name=name,
data_type=text_or_empty(node, "data_type"),
inventory_display=text_or_empty(node, "inventory_display"),
input_type=text_or_empty(node, "input_type/type"),
)
)
return results
def fetch_smart_group_summaries(client: JamfClient) -> list[tuple[int, str]]:
root = client.get_classic_xml("/JSSResource/computergroups")
results: list[tuple[int, str]] = []
for node in root.findall("./computer_group"):
group_id = text_or_empty(node, "id")
name = text_or_empty(node, "name")
is_smart = text_or_empty(node, "is_smart").lower() == "true"
if group_id and name and is_smart:
results.append((int(group_id), name))
return results
def fetch_smart_group_detail(client: JamfClient, group_id: int, name: str) -> SmartGroup:
root = client.get_classic_xml(f"/JSSResource/computergroups/id/{group_id}")
criteria_names: list[str] = []
for criterion in root.findall(".//criteria/criterion"):
criterion_name = text_or_empty(criterion, "name")
if criterion_name:
criteria_names.append(criterion_name)
return SmartGroup(id=group_id, name=name, criteria_names=criteria_names)
def analyze_usage(
extension_attributes: list[ExtensionAttribute],
smart_groups: list[SmartGroup],
) -> list[dict[str, Any]]:
usage: dict[int, dict[str, Any]] = {
ea.id: {
"id": ea.id,
"name": ea.name,
"data_type": ea.data_type,
"inventory_display": ea.inventory_display,
"input_type": ea.input_type,
"smart_group_count": 0,
"criteria_hits": 0,
"smart_group_names": [],
}
for ea in extension_attributes
}
ea_name_to_id = {ea.name: ea.id for ea in extension_attributes}
for group in smart_groups:
matched_ids: set[int] = set()
for criterion_name in group.criteria_names:
ea_id = ea_name_to_id.get(criterion_name)
if ea_id is None:
continue
usage[ea_id]["criteria_hits"] += 1
matched_ids.add(ea_id)
for ea_id in matched_ids:
usage[ea_id]["smart_group_count"] += 1
usage[ea_id]["smart_group_names"].append(group.name)
total_groups = len(smart_groups)
rows = list(usage.values())
for row in rows:
if total_groups:
row["coverage_percent"] = round(
(row["smart_group_count"] / total_groups) * 100, 2
)
else:
row["coverage_percent"] = 0.0
return sorted(
rows,
key=lambda row: (
row["smart_group_count"],
row["criteria_hits"],
row["name"].lower(),
),
reverse=True,
)
def print_table(rows: list[dict[str, Any]], total_groups: int, limit: int | None = None) -> None:
display_rows = rows if limit is None else rows[:limit]
headers = [
"Rank",
"EA Name",
"Smart Groups",
"Criteria Hits",
"Coverage",
]
widths = [4, 30, 12, 13, 8]
print(
f"{headers[0]:<{widths[0]}} {headers[1]:<{widths[1]}} "
f"{headers[2]:>{widths[2]}} {headers[3]:>{widths[3]}} {headers[4]:>{widths[4]}}"
)
print(
f"{'-' * widths[0]} {'-' * widths[1]} "
f"{'-' * widths[2]} {'-' * widths[3]} {'-' * widths[4]}"
)
for idx, row in enumerate(display_rows, start=1):
print(
f"{idx:<{widths[0]}} "
f"{row['name'][:widths[1]]:<{widths[1]}} "
f"{row['smart_group_count']:>{widths[2]}} "
f"{row['criteria_hits']:>{widths[3]}} "
f"{row['coverage_percent']:>{widths[4]}.2f}%"
)
print()
print(f"Total extension attributes: {len(rows)}")
print(f"Total smart groups scanned: {total_groups}")
def write_json(path: str, rows: list[dict[str, Any]], total_groups: int) -> None:
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"total_extension_attributes": len(rows),
"total_smart_groups": total_groups,
"rows": rows,
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
def write_html(path: str, rows: list[dict[str, Any]], total_groups: int) -> None:
max_groups = max((row["smart_group_count"] for row in rows), default=0)
html: list[str] = [
"<!doctype html>",
"<html lang='en'>",
"<head>",
"<meta charset='utf-8'>",
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
"<title>Jamf Extension Attribute Usage Report</title>",
"<style>",
"body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 2rem; background: #f5f7fb; color: #162033; }",
".wrap { max-width: 1100px; margin: 0 auto; }",
".card { background: white; border-radius: 14px; padding: 1.25rem 1.5rem; box-shadow: 0 10px 30px rgba(17, 24, 39, 0.08); margin-bottom: 1rem; }",
"h1, h2 { margin: 0 0 0.75rem 0; }",
".meta { display: flex; gap: 1rem; flex-wrap: wrap; color: #51607a; font-size: 0.95rem; margin-bottom: 1rem; }",
"table { width: 100%; border-collapse: collapse; }",
"th, td { text-align: left; padding: 0.65rem 0.5rem; border-bottom: 1px solid #e5eaf3; vertical-align: top; }",
"th { color: #3b4a66; font-size: 0.9rem; letter-spacing: 0.02em; }",
".bar { width: 220px; height: 12px; border-radius: 999px; background: #e7edf7; overflow: hidden; }",
".bar span { display: block; height: 12px; background: linear-gradient(90deg, #2a6df4, #4f9cf9); border-radius: 999px; }",
".small { color: #6b7a93; font-size: 0.9rem; }",
".groups { color: #4a5872; font-size: 0.92rem; }",
"</style>",
"</head>",
"<body>",
"<div class='wrap'>",
"<div class='card'>",
"<h1>Jamf Extension Attribute Usage Report</h1>",
f"<div class='meta'><div>Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</div><div>Extension Attributes: {len(rows)}</div><div>Smart Groups: {total_groups}</div></div>",
"<p class='small'>Read-only ranking of computer extension attributes by unique smart-group references.</p>",
"</div>",
"<div class='card'>",
"<table>",
"<thead><tr><th>Rank</th><th>Name</th><th>Smart Groups</th><th>Criteria Hits</th><th>Visual</th><th>Groups</th></tr></thead>",
"<tbody>",
]
for idx, row in enumerate(rows, start=1):
bar_width = 0 if max_groups == 0 else int((row["smart_group_count"] / max_groups) * 100)
group_names = ", ".join(row["smart_group_names"][:8])
if len(row["smart_group_names"]) > 8:
group_names += ", ..."
html.append(
"<tr>"
f"<td>{idx}</td>"
f"<td><strong>{row['name']}</strong><div class='small'>ID {row['id']} · {row['data_type'] or 'Unknown type'}</div></td>"
f"<td>{row['smart_group_count']}<div class='small'>{row['coverage_percent']:.2f}% of smart groups</div></td>"
f"<td>{row['criteria_hits']}</td>"
f"<td><div class='bar'><span style='width:{bar_width}%;'></span></div></td>"
f"<td class='groups'>{group_names or '—'}</td>"
"</tr>"
)
html.extend(["</tbody>", "</table>", "</div>", "</div>", "</body>", "</html>"])
with open(path, "w", encoding="utf-8") as handle:
handle.write("\n".join(html))
def run_self_test() -> int:
ea_xml = """
<computer_extension_attributes>
<computer_extension_attribute>
<id>1</id><name>Secure Token Holders</name><data_type>String</data_type>
</computer_extension_attribute>
<computer_extension_attribute>
<id>2</id><name>Compliance Failed Results</name><data_type>String</data_type>
</computer_extension_attribute>
<computer_extension_attribute>
<id>3</id><name>Last Restart</name><data_type>String</data_type>
</computer_extension_attribute>
</computer_extension_attributes>
"""
group_one_xml = """
<computer_group>
<id>10</id><name>Needs Attention</name>
<criteria>
<criterion><name>Compliance Failed Results</name></criterion>
<criterion><name>Secure Token Holders</name></criterion>
</criteria>
</computer_group>
"""
group_two_xml = """
<computer_group>
<id>11</id><name>Token Exceptions</name>
<criteria>
<criterion><name>Secure Token Holders</name></criterion>
</criteria>
</computer_group>
"""
eas = []
root = ET.fromstring(ea_xml)
for node in root.findall("./computer_extension_attribute"):
eas.append(
ExtensionAttribute(
id=int(text_or_empty(node, "id")),
name=text_or_empty(node, "name"),
data_type=text_or_empty(node, "data_type"),
)
)
groups = []
for xml_text in (group_one_xml, group_two_xml):
node = ET.fromstring(xml_text)
groups.append(
SmartGroup(
id=int(text_or_empty(node, "id")),
name=text_or_empty(node, "name"),
criteria_names=[
text_or_empty(c, "name")
for c in node.findall(".//criteria/criterion")
if text_or_empty(c, "name")
],
)
)
rows = analyze_usage(eas, groups)
print_table(rows, total_groups=len(groups))
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Rank Jamf Pro computer extension attributes by smart-group usage."
)
parser.add_argument("--url", default=os.getenv("JAMF_URL"), help="Jamf Pro base URL")
parser.add_argument("--client-id", default=os.getenv("JAMF_CLIENT_ID"))
parser.add_argument("--client-secret", default=os.getenv("JAMF_CLIENT_SECRET"))
parser.add_argument("--user", default=os.getenv("JAMF_USER"))
parser.add_argument("--password", default=os.getenv("JAMF_PASSWORD"))
parser.add_argument("--json-out", help="Optional path for JSON output")
parser.add_argument("--html-out", help="Optional path for HTML output")
parser.add_argument("--top", type=int, default=None, help="Limit terminal output to top N rows")
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--self-test", action="store_true", help="Run parser/report self-test without Jamf access")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.self_test:
return run_self_test()
if not args.url:
print("Error: provide --url or set JAMF_URL", file=sys.stderr)
return 2
client = JamfClient(
args.url,
client_id=args.client_id,
client_secret=args.client_secret,
user=args.user,
password=args.password,
verify_tls=not args.insecure,
timeout=args.timeout,
)
try:
client.authenticate()
extension_attributes = fetch_extension_attributes(client)
smart_group_summaries = fetch_smart_group_summaries(client)
smart_groups = [
fetch_smart_group_detail(client, group_id, group_name)
for group_id, group_name in smart_group_summaries
]
rows = analyze_usage(extension_attributes, smart_groups)
except JamfApiError as exc:
print(f"Jamf API error: {exc}", file=sys.stderr)
return 1
print_table(rows, total_groups=len(smart_groups), limit=args.top)
if args.json_out:
write_json(args.json_out, rows, total_groups=len(smart_groups))
print(f"Wrote JSON report to {args.json_out}")
if args.html_out:
write_html(args.html_out, rows, total_groups=len(smart_groups))
print(f"Wrote HTML report to {args.html_out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sources
- Extension Attributes
- Acting on Extension Attribute Data
- Client Credentials
- Jamf Pro API Overview
- Creating and Populating Values
- Jamf Extension Attributes
Ready to take your Apple IT skills and consulting career to the next level?
I’m opening up free mentorship slots to help you navigate certifications, real-world challenges, and starting your own independent consulting business.
Let’s connect and grow together — Sign up here
AI Usage Transparency Report
AI Era · Written during widespread use of AI tools
AI Signal Composition
Score: 0.32 · Moderate AI Influence
Summary
This script authenticates to Jamf Pro, reads the computer extension attribute list, reads the computer group list, filters down to Smart Computer Groups, then walks each group’s criteria and counts where an extension attribute name shows up.
Related Posts
Automating JAMF Pro Email Notifications with SendGrid (Smart Group Driven Workflows)
Modern device management isn't just about enforcing policies—it's about communicating effectively with users at the right time. In JAMF Pro, Smart Groups give you powerful visibility into device state, but they don't natively solve the problem of proactive, automated user communication. Whether you're trying to prompt users to restart their machines, complete updates, or take action on compliance issues, bridging that gap requires a flexible and scalable notification system.
The Day I Unmanaged a Mac Into a Corner
There are a few kinds of mistakes you make as a Mac admin. There are the ones that cost you time, the ones that cost you sleep, and then there are the ones that leave you staring at a perfectly good laptop thinking, “How did I possibly make this *less* manageable by touching it?” These mistakes often stem from a lack of understanding or experience with macOS, but they can also be the result of rushing through tasks or not taking the time to properly plan and test.
Updating Safari on macOS with Jamf Pro: Three Practical Strategies
Keeping Safari updated is one of the simplest ways to harden a macOS fleet. Apple ships security fixes for Safari frequently, and those patches often land before a full macOS point release. This means that by keeping Safari up-to-date, you can ensure your users have access to the latest security protections without having to wait for a major operating system update. If Safari is lagging behind, your users are browsing the web with a larger attack surface than necessary.
Hunting Down Jamf Profile Payloads with Python
If you've spent enough time living inside Jamf Pro, you eventually run into the same problem: someone set a configuration somewhere, sometime, and nobody remembers where. It might be something obscure – a certificate payload, a conditional SSO predicate, or that one security preference quietly misbehaving on three machines in accounting. And when you have dozens of configuration profiles, each with multiple payloads, nested keys, and XML-wrapped values, finding that setting can feel like forensic archaeology.
Keeping Jamf Security Cloud Current for Microsoft 365: Updated Routing Policies
When I first wrote about troubleshooting Standard Routing Policies in Jamf Security Cloud, the goal was simple: help admins keep Microsoft Teams and Microsoft 365 traffic flowing smoothly through Jamf Trust + App-Based VPN. This straightforward objective remains unchanged, as the complexities of network configurations can often lead to frustrating issues that hinder productivity.
Cleaning House in Jamf Pro: A Friendly Auditor Script for Real-World Hygiene
There’s a tipping point in every Jamf Pro environment where the policy list begins to feel like a junk drawer. Everyone means well. Nobody deletes anything. And then, months later, you’re trying to answer simple questions like: *Which policies are actually scoped? What’s no longer referenced? Why are there five versions of the same script?* This post covers a small, practical script I wrote to help you **see** what’s stale, **explain** why it’s stale, and (optionally) **park** it safely out of the way—without deleting a thing.
Turn Jamf Compliance Output into Real Audit Evidence
Most teams use Apple’s macOS Security Compliance Project (mSCP) baselines because they scale and they’re repeatable. Jamf’s tooling makes deployment straightforward and the Extension Attribute (EA) output is a convenient place to capture drift. What you don’t automatically get is the artifact an auditor will accept on a specific date—an actual document you can file that shows which endpoints are failing which items, plus a concise roll-up of failure counts you can act on. Smart Groups answer scope; they don’t produce evidence.
The Power of Scripting App Updates Without Deploying Packages
Keeping macOS environments up-to-date in a seamless, efficient, and low-maintenance way has always been a challenge for IT admins. Traditional package deployment workflows can be time-consuming, prone to versioning issues, and require extensive testing and repackaging. This can lead to frustration and wasted resources as IT teams struggle to keep pace with the latest updates and patches. But there's another way—a more elegant, nimble approach: scripting.
Detecting Invalid Characters and Long Paths in OneDrive on macOS
Microsoft OneDrive is widely used for syncing documents across devices, but on macOS, it can silently fail to sync certain files if they violate Windows filesystem rules — like overly long paths or invalid characters. This creates frustrating experiences for end users who don’t know why files aren’t syncing.