Author SHA1 Message Date
Wesley van TilburgandClaude Opus 5.5 3df9ce77d6 fedora-intune: Defender script also turns on behavior monitoring
Behavior monitoring is off by default and the repair script never
checked it, so on devices without the image's managed config (another
distro, a plain install) it stayed off and the compliance rule failed.
Enable it, and cloud-delivered protection, when they're off and not
managed.

Only start mdatp-firstboot where the image installed it; elsewhere every
run logged that first-boot setup hadn't completed and tried to start a
unit that doesn't exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 11:31:14 +00:00
Misthios 99400fb03e Merge pull request 'fedora-intune: query Defender through systemd-run instead of a snapshot' (#8) from misthios/defender-query-via-systemd-run into main
Build containers / Compute changes (push) Successful in 21s
Build containers / fedora-cosmic (44) (push) Skipped
Build containers / fedora-cosmic (rawhide) (push) Skipped
Build containers / fedora-remote (44) (push) Skipped
Build containers / fedora-intune (44) (push) Successful in 12m25s
Build containers / Prune old releases and tags (push) Successful in 27s
Reviewed-on: #8
2026-10-20 14:16:28 +00:00
Wesley van TilburgandClaude Opus 5.5 ca115922bd fedora-intune: query Defender through systemd-run instead of a snapshot
Build containers / fedora-cosmic (44) (pull_request) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (pull_request) Canceled after 0s
Build containers / fedora-remote (44) (pull_request) Canceled after 0s
Build containers / fedora-intune (44) (pull_request) Canceled after 0s
Build containers / Compute changes (pull_request) Canceled after 14s
Build containers / Prune old releases and tags (pull_request) Skipped
The health snapshot's temp directory, chmod and mv in /run every 5
minutes showed up in Defender's timeline as "Data Staged" and
"Executable permissions were added". Drop the snapshot service and
timer. The compliance script now runs `mdatp health` through
systemd-run, so systemd starts mdatp and Defender answers it even when
the script runs inside himmelblaud-tasks. Without systemd-run access it
asks mdatp directly, as before. No image support needed, so the script
works on other distros as is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 14:12:59 +00:00
Misthios 082ae219b1 Merge pull request 'fedora-intune: take the first Defender health snapshot right after boot' (#7) from misthios/defender-snapshot-at-boot into main
Build containers / Compute changes (push) Successful in 20s
Build containers / fedora-cosmic (44) (push) Skipped
Build containers / fedora-cosmic (rawhide) (push) Skipped
Build containers / fedora-remote (44) (push) Skipped
Build containers / fedora-intune (44) (push) Successful in 12m28s
Build containers / Prune old releases and tags (push) Successful in 27s
Reviewed-on: #7
2026-10-20 14:02:43 +00:00
Wesley van TilburgandClaude Opus 5.5 49b360dffa fedora-intune: take the first Defender health snapshot right after boot
Build containers / fedora-cosmic (44) (pull_request) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (pull_request) Canceled after 0s
Build containers / fedora-remote (44) (pull_request) Canceled after 0s
Build containers / fedora-intune (44) (pull_request) Canceled after 0s
Build containers / Compute changes (pull_request) Canceled after 12s
Build containers / Prune old releases and tags (pull_request) Skipped
The first snapshot came 2 minutes after boot, so the compliance check at
the first sign-in found none, fell back to mdatp (which won't answer it)
and reported Defender as not running. Himmelblau then doesn't check again
for 8 hours. Start the timer 20 seconds after boot, and wait up to
2 minutes for the daemon to answer so that snapshot isn't a false
"not running". The timer runs it in the background, so boot isn't held up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 13:47:07 +00:00
Misthios 811025773e Merge pull request 'fedora-intune: give the Intune compliance check a Defender health snapshot' (#6) from misthios/defender-health-snapshot into main
Build containers / Compute changes (push) Successful in 19s
Build containers / fedora-cosmic (44) (push) Skipped
Build containers / fedora-cosmic (rawhide) (push) Skipped
Build containers / fedora-remote (44) (push) Skipped
Build containers / fedora-intune (44) (push) Successful in 12m31s
Build containers / Prune old releases and tags (push) Successful in 26s
Reviewed-on: #6
2026-10-20 13:25:02 +00:00
Wesley van TilburgandClaude Opus 5.5 111d3bea66 fedora-intune: give the Intune compliance check a Defender health snapshot
Build containers / fedora-cosmic (44) (pull_request) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (pull_request) Canceled after 0s
Build containers / fedora-remote (44) (pull_request) Canceled after 0s
Build containers / fedora-intune (44) (pull_request) Canceled after 0s
Build containers / Compute changes (pull_request) Canceled after 9s
Build containers / Prune old releases and tags (pull_request) Skipped
Himmelblau runs the custom compliance script inside himmelblaud-tasks,
and Defender refuses to answer `mdatp health` there: the daemon can't
read /proc/<himmelblaud_tasks>/cmdline while checking the caller
("Permission denied" in microsoft_defender_err.log). The script saw no
daemon, and every Defender rule failed although Defender was healthy.

Add mdatp-health-snapshot.timer, which saves the health fields to
/run/mdatp-health every 5 minutes as root, outside that service. The
compliance script reads the snapshot when it's under 20 minutes old and
otherwise asks mdatp directly, so it still works on other distros.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 13:23:17 +00:00
Misthios 4d780e900a Merge pull request 'Misthios/fedora intune defender os version' (#5) from misthios/fedora-intune-defender-os-version into main
Build containers / Compute changes (push) Successful in 22s
Build containers / fedora-cosmic (44) (push) Skipped
Build containers / fedora-cosmic (rawhide) (push) Skipped
Build containers / fedora-remote (44) (push) Skipped
Build containers / fedora-intune (44) (push) Successful in 12m35s
Build containers / Prune old releases and tags (push) Successful in 26s
Reviewed-on: #5
2026-10-20 12:49:33 +00:00
Wesley van TilburgandClaude Opus 5.5 e8cfa8d4cf fedora-intune: don't repeat the version in the reported OS version
Build containers / fedora-cosmic (44) (pull_request) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (pull_request) Canceled after 0s
Build containers / fedora-remote (44) (pull_request) Canceled after 0s
Build containers / fedora-intune (44) (pull_request) Canceled after 0s
Build containers / Compute changes (pull_request) Canceled after 13s
Build containers / Prune old releases and tags (pull_request) Skipped
libhimmelblau reports "PRETTY_NAME VERSION_ID" as the OS version, and
Fedora's PRETTY_NAME already contains the version, so Entra showed
"Fedora Linux 44.20261020.0 (Forty Four) 44.20261020.0". In the
os-release the Himmelblau daemons see, set PRETTY_NAME to NAME, giving
"Fedora Linux 44.20261020.0". Nothing else in Himmelblau reads
PRETTY_NAME.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:48:44 +00:00
Wesley van TilburgandClaude Opus 5.5 5504f4e7aa fedora-intune: install the Defender managed config when Defender starts
The managed config shipped in /etc never reached updated devices:
mdatp-firstboot creates /etc/opt/microsoft/mdatp on the device, so ostree's
/etc merge treats that directory as local and doesn't add new image files
under it. VM 123 had the file in /usr/etc but not in /etc, and Defender
kept its defaults.

Ship it in /usr/share/mdatp-managed/ and install it into
/etc/opt/microsoft/mdatp/managed/ from an ExecStartPre in mdatp.service.
This also applies changes from newer images and undoes local edits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:48:44 +00:00
Misthios 561036582c Merge pull request 'Misthios/defender onboarding validation' (#4) from misthios/defender-onboarding-validation into main
Build containers / Compute changes (push) Successful in 21s
Build containers / fedora-cosmic (44) (push) Skipped
Build containers / fedora-cosmic (rawhide) (push) Skipped
Build containers / fedora-remote (44) (push) Skipped
Build containers / fedora-intune (44) (push) Successful in 12m40s
Build containers / Prune old releases and tags (push) Successful in 28s
Reviewed-on: #4
2026-10-20 12:23:30 +00:00
Wesley van TilburgandClaude Opus 5.5 86039eeb9d fedora-intune: enforce Defender protection settings
Defender's defaults left behavior monitoring off and every setting
unmanaged. Ship /etc/opt/microsoft/mdatp/managed/mdatp_managed.json:
real-time protection, behavior monitoring, PUA blocking, cloud-delivered
protection at the high block level, automatic definition updates and
sample submission, and scheduled scans (daily quick at 12:00, weekly full
on Wednesday 12:30, low priority). Users can't change managed settings.
The build fails if the file isn't valid JSON.

Network protection (Insiders channels only) and tamper protection
(audit-only preview on Linux) are left out.

The compliance script and rules now also check behavior monitoring and
cloud-delivered protection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:23:30 +00:00
Wesley van TilburgandClaude Opus 5.5 3afef0d821 fedora-intune: validate the onboarding JSON in the Defender script
A paste into Intune that lost the first "{" line produced an unreadable
mdatp_onboard.json, and Defender reported "No license found". Check that
the pasted JSON parses and has onboardingInfo before writing it, and log
what to fix otherwise.

The placeholder also appeared twice, so a find-and-replace of it broke
the script; match it by prefix so it appears only once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:23:30 +00:00
Misthios 07434cedab Merge pull request 'fedora-intune: grant sudo to the Linux admins Entra group' (#3) from misthios/fedora-intune-sudo-groups into main
Build containers / fedora-cosmic (44) (push) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (push) Canceled after 0s
Build containers / fedora-remote (44) (push) Canceled after 0s
Build containers / fedora-intune (44) (push) Canceled after 0s
Build containers / Compute changes (push) Canceled after 12s
Build containers / Prune old releases and tags (push) Successful in 26s
Reviewed-on: #3
2026-10-20 12:22:57 +00:00
Wesley van TilburgandClaude Opus 5.5 0e76505715 fedora-intune: grant sudo to the Linux admins Entra group
Build containers / fedora-cosmic (44) (pull_request) Canceled after 0s
Build containers / fedora-cosmic (rawhide) (pull_request) Canceled after 0s
Build containers / fedora-remote (44) (pull_request) Canceled after 0s
Build containers / fedora-intune (44) (pull_request) Canceled after 0s
Build containers / Compute changes (pull_request) Canceled after 12s
Build containers / Prune old releases and tags (pull_request) Skipped
local_sudo_group was set to wheel but sudo_groups was empty, so no Entra
user ever got sudo. Members of the Entra group
c3b1cfcc-7834-47eb-948b-a22204276453 now get wheel at sign-in, and lose
it again after leaving the group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 11:30:20 +00:00
8 changed files with 136 additions and 16 deletions

No files matched your search

+14
View File
@@ -26,6 +26,18 @@ add-files:
- ["mdatp-firstboot.service", "/usr/lib/systemd/system/mdatp-firstboot.service"]
- ["mdatp-firstboot.conf", "/usr/lib/systemd/system/mdatp.service.d/10-firstboot.conf"]
- ["50-defender.preset", "/usr/lib/systemd/system-preset/50-defender.preset"]
# Enforced Defender settings (shown as "[managed]" in `mdatp health`; local
# changes are refused): real-time protection, behavior monitoring, PUA
# blocking, cloud protection at the "high" block level, automatic updates
# and sample submission, a daily quick scan at 12:00 and a weekly full scan
# on Wednesday 12:30 (low priority, up to 2 h later at random). Settings
# reference: https://learn.microsoft.com/defender-endpoint/linux-preferences
# Not set here: network protection (Insiders channels only) and tamper
# protection (audit-only preview on Linux).
- ["mdatp_managed.json", "/usr/share/mdatp-managed/mdatp_managed.json"]
# Installed into /etc/opt/microsoft/mdatp/managed/ whenever Defender starts;
# see the drop-in.
- ["mdatp-managed-config.conf", "/usr/lib/systemd/system/mdatp.service.d/20-managed-config.conf"]
postprocess:
- |
@@ -42,5 +54,7 @@ postprocess:
[[ -f "${pp}" ]] && break
done
semodule -n -i "${pp}"
# Fail the build on a typo rather than ship a config Defender can't read.
python3 -m json.tool /usr/share/mdatp-managed/mdatp_managed.json > /dev/null
rpm -q --qf '%{POSTIN}' mdatp > /usr/libexec/mdatp-postinst
chmod 0755 /usr/libexec/mdatp-firstboot /usr/libexec/mdatp-postinst
@@ -5,16 +5,23 @@
# "44". Himmelblau reports VERSION_ID as the device's OS version and has no
# setting for it. The rest of the system keeps the stock VERSION_ID, which
# toolbox, flatpak and dnf expect to be the plain release.
#
# PRETTY_NAME becomes just NAME: libhimmelblau reports "PRETTY_NAME VERSION_ID"
# as the OS version string, and Fedora's PRETTY_NAME already contains the
# version ("Fedora Linux 44.20261020.0 (Forty Four) 44.20261020.0"). Nothing
# else in Himmelblau reads PRETTY_NAME.
set -euo pipefail
SRC=/usr/lib/os-release
DEST=/run/himmelblau-os-release
version="$(. "${SRC}" && echo "${OSTREE_VERSION:-${IMAGE_VERSION:-}}")"
name="$(. "${SRC}" && echo "${NAME:-Linux}")"
tmp="$(mktemp "${DEST}.XXXXXX")"
if [[ -n "${version}" ]]; then
sed "s/^VERSION_ID=.*/VERSION_ID=${version}/" "${SRC}" > "${tmp}"
sed -e "s/^VERSION_ID=.*/VERSION_ID=${version}/" \
-e "s/^PRETTY_NAME=.*/PRETTY_NAME=\"${name}\"/" "${SRC}" > "${tmp}"
else
# No image version (not an ostree/bootc deployment): pass it through as-is.
cat "${SRC}" > "${tmp}"
@@ -11,6 +11,9 @@ apply_policy = true
# Fedora's admin group; members of sudo_groups (Entra group object IDs) are
# added to it.
local_sudo_group = wheel
# Linux admins: members get wheel (sudo) at sign-in, and lose it again within
# local_groups_reconcile_interval after leaving the group.
sudo_groups = c3b1cfcc-7834-47eb-948b-a22204276453
# Home directories named after the account (e.g. /home/jdoe), with a UPN alias.
home_attr = CN
home_alias = SPN
@@ -0,0 +1,7 @@
# Install the image's enforced Defender settings each time Defender starts.
# The image can't ship them in /etc directly: mdatp-firstboot creates
# /etc/opt/microsoft/mdatp on the device, so on updates that directory counts
# as local and ostree's /etc merge never adds new files under it. Copying on
# every start also picks up changes from newer images and undoes local edits.
[Service]
ExecStartPre=+/usr/bin/install -D -Z -m 0644 /usr/share/mdatp-managed/mdatp_managed.json /etc/opt/microsoft/mdatp/managed/mdatp_managed.json
@@ -0,0 +1,29 @@
{
"antivirusEngine": {
"enforcementLevel": "real_time",
"behaviorMonitoring": "enabled",
"scanAfterDefinitionUpdate": true,
"scanArchives": true,
"disallowedThreatActions": ["allow", "restore"],
"threatTypeSettingsMergePolicy": "admin_only",
"threatTypeSettings": [
{ "key": "potentially_unwanted_application", "value": "block" },
{ "key": "archive_bomb", "value": "audit" }
],
"scheduledScan": "enabled"
},
"scheduledScan": {
"dailyConfiguration": { "timeOfDay": 720 },
"weeklyConfiguration": { "dayOfWeek": 4, "scanType": "full", "timeOfDay": 750 },
"lowPriorityScheduledScan": true,
"checkForDefinitionsUpdate": true,
"randomizeScanStartTime": 2
},
"cloudService": {
"enabled": true,
"diagnosticLevel": "required",
"automaticSampleSubmissionConsent": "safe",
"automaticDefinitionUpdateEnabled": true,
"cloudBlockLevel": "high"
}
}
@@ -84,6 +84,34 @@
}
]
},
{
"SettingName": "DefenderBehaviorMonitoring",
"Operator": "IsEquals",
"DataType": "Boolean",
"Operand": true,
"MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/linux-install-manually",
"RemediationStrings": [
{
"Language": "en_US",
"Title": "Behavior monitoring is off.",
"Description": "Microsoft Defender behavior monitoring must be enabled. Update the device to the latest image or contact IT."
}
]
},
{
"SettingName": "DefenderCloudProtection",
"Operator": "IsEquals",
"DataType": "Boolean",
"Operand": true,
"MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/linux-install-manually",
"RemediationStrings": [
{
"Language": "en_US",
"Title": "Cloud-delivered protection is off.",
"Description": "Microsoft Defender cloud-delivered protection must be enabled. Update the device to the latest image or contact IT."
}
]
},
{
"SettingName": "DefenderDefinitionsUpToDate",
"Operator": "IsEquals",
@@ -2,20 +2,26 @@
# Intune custom compliance discovery script for Linux: is Microsoft Defender
# for Endpoint (mdatp) installed, onboarded, licensed and protecting?
#
# Upload in Intune > Devices > Compliance > Scripts (Linux), and pair it with
# defender-compliance.json in a Linux compliance policy (Custom compliance).
# Himmelblau runs it as root inside himmelblaud-tasks.service and sends the
# JSON on stdout to Intune, which evaluates it against the rules. Every setting
# is always emitted, so a missing or stopped Defender reads as non-compliant
# instead of as a script error.
# Upload in Intune > Endpoint security > Device compliance > Scripts (Linux),
# and pair it with defender-compliance.json in a Linux compliance policy
# (Custom compliance). Himmelblau runs it as root inside
# himmelblaud-tasks.service and sends the JSON on stdout to Intune, which
# evaluates it against the rules. Every setting is always emitted, so a missing
# or stopped Defender reads as non-compliant instead of as a script error.
MDATP=/usr/bin/mdatp
# Print one `mdatp health` field without quotes or a trailing " [managed]".
# Unlicensed or unonboarded, mdatp prints "ATTENTION: ..." lines first.
# Defender won't answer `mdatp health` started from inside himmelblaud-tasks
# (it can't read that process while checking the caller), so ask through
# systemd, which runs mdatp as its own child. Elsewhere, ask mdatp directly.
health="$(systemd-run --quiet --wait --pipe --collect "${MDATP}" health 2>/dev/null)"
grep -q '^healthy' <<< "${health}" || health="$("${MDATP}" health 2>/dev/null)"
# Print one field of the `mdatp health` output ("name : value") without
# quotes or a trailing " [managed]".
field() {
"${MDATP}" health --field "$1" 2>/dev/null | grep -v '^ATTENTION' | tail -n1 \
| sed -e 's/ \[managed\]$//' -e 's/^"//' -e 's/"$//'
grep -m1 "^$1 *:" <<< "${health}" \
| sed -e 's/^[^:]*: *//' -e 's/ \[managed\]$//' -e 's/^"//' -e 's/"$//'
}
bool() {
@@ -28,6 +34,8 @@ healthy=false
licensed=false
onboarded=false
rtp=false
behavior_monitoring=false
cloud_protection=false
definitions_ok=false
definitions_age=-1
version=""
@@ -40,6 +48,8 @@ if [[ -x "${MDATP}" ]]; then
healthy="$(bool "$(field healthy)")"
licensed="$(bool "$(field licensed)")"
rtp="$(bool "$(field real_time_protection_enabled)")"
[[ "$(field behavior_monitoring)" == enabled ]] && behavior_monitoring=true
cloud_protection="$(bool "$(field cloud_enabled)")"
[[ -n "$(field org_id)" ]] && onboarded=true
[[ "$(field definitions_status)" == up_to_date ]] && definitions_ok=true
age="$(field definitions_updated_minutes_ago)"
@@ -57,6 +67,8 @@ cat <<EOF
"DefenderLicensed": ${licensed},
"DefenderOnboarded": ${onboarded},
"DefenderRealTimeProtection": ${rtp},
"DefenderBehaviorMonitoring": ${behavior_monitoring},
"DefenderCloudProtection": ${cloud_protection},
"DefenderDefinitionsUpToDate": ${definitions_ok},
"DefenderDefinitionsAgeDays": ${definitions_age},
"DefenderVersion": "${version}"
@@ -56,7 +56,7 @@ if [[ ! -x "${MDATP}" ]]; then
fi
# The image finishes the Defender install on first boot (mdatp-firstboot).
if [[ ! -f /var/lib/mdatp-firstboot.done ]]; then
if [[ -x /usr/libexec/mdatp-firstboot && ! -f /var/lib/mdatp-firstboot.done ]]; then
log "Defender first-boot setup has not completed; starting it."
systemctl start mdatp-firstboot.service
fi
@@ -71,8 +71,16 @@ if ! wait_for_daemon; then
fi
if [[ -z "$(field org_id)" ]]; then
if [[ "${ONBOARDING_JSON}" == REPLACE_WITH_MDATP_ONBOARD_JSON ]]; then
# Prefix match, so the placeholder appears only once in this file and a
# find-and-replace of it can't hit this line.
if [[ "${ONBOARDING_JSON}" == REPLACE_WITH_* ]]; then
log "Defender is not onboarded and this script has no onboarding JSON."
elif ! printf '%s\n' "${ONBOARDING_JSON}" \
| python3 -c 'import json, sys; assert "onboardingInfo" in json.load(sys.stdin)' 2>/dev/null; then
# A paste that lost the first "{" or last "}" line would otherwise leave
# Defender with an unreadable onboarding file ("No license found").
log "The onboarding JSON in this script is not valid; paste mdatp_onboard.json again, including its first '{' and last '}' lines."
exit 1
else
log "Defender is not onboarded; writing ${ONBOARD_FILE}."
install -d -m 0755 "$(dirname "${ONBOARD_FILE}")"
@@ -85,18 +93,30 @@ if [[ -z "$(field org_id)" ]]; then
fi
fi
# Settings Defender's own policy manages show " [managed]" and can't be
# changed locally; this only fixes a locally disabled real-time protection.
# Settings a managed config (mdatp_managed.json) sets show " [managed]" and
# can't be changed locally; these fix devices without one, where behavior
# monitoring is off by default. Behavior monitoring needs real-time
# protection, so that goes first.
if [[ "$(field real_time_protection_enabled)" != true ]]; then
log "Real-time protection is off; enabling it."
"${MDATP}" config real-time-protection --value enabled
fi
if [[ "$(field behavior_monitoring)" != enabled ]]; then
log "Behavior monitoring is off; enabling it."
"${MDATP}" config behavior-monitoring --value enabled
fi
if [[ "$(field cloud_enabled)" != true ]]; then
log "Cloud-delivered protection is off; enabling it."
"${MDATP}" config cloud --value enabled
fi
if [[ "$(field definitions_status)" != up_to_date ]]; then
log "Definitions are not up to date; updating them."
"${MDATP}" definitions update
fi
healthy="$(field healthy)"
log "healthy=${healthy} licensed=$(field licensed) org_id=$(field org_id) rtp=$(field real_time_protection_enabled) definitions=$(field definitions_status)"
log "healthy=${healthy} licensed=$(field licensed) org_id=$(field org_id) rtp=$(field real_time_protection_enabled) behavior_monitoring=$(field behavior_monitoring) cloud=$(field cloud_enabled) definitions=$(field definitions_status)"
[[ "${healthy}" == true ]]