Drift alerts to Slack, Teams or your own webhook.

Every one-shot compliance scan writes what changed since the previous scan beside its pack. With one flag it can also post that drift to a channel or to a receiver you build. This page shows how to get the URL, which flags to add, what is sent, and the JSON a receiver gets.

Slack — incoming webhook Teams — Workflows webhook Opt-in · minimal by default EE 2.0.0 · CE 1.0.0

01Two senders

Drift notificationWatch alert
EditionEnterpriseCommunity
Fires ona one-shot scan with --compliance, when its drift notifies (section 04) — at most one notification per host per framework whose report rendereda --watch cycle, for each host whose scan changed since the previous cycle and has a finding at or above --alert-severity; the first cycle and a failed scan never alert (with --alert-every-cycle, every such host on every cycle)
Flag--notify-webhook <url>--webhook-url <url>
URL schemehttps onlyhttp or https
Formatsgeneric, slack, teamsone JSON shape
Schema idnsauditor.drift-notification/1none
Outcome recordedscan_drift_<fw>.json → notificationprinted only — a success to stdout, a failure to stderr

Neither is available through the MCP server, and the drift notification is never sent from --watch.

02Get the webhook URL

Slack (steps as of October 2026)

  • Open api.slack.com/apps and click Create an App.
  • Choose Blank app (under “Or start your own way”), then Continue. The templates and the Slack CLI route are for apps that run code; this one only receives messages.
  • Name the app (for example NSAuditor drift alerts) and pick your workspace.
  • On What it can do in Slack?, search incoming-webhook and add it under Bot scopes. Leave User scopes empty. That is the only scope the scanner needs.
  • On What it can respond to in Slack?, add nothing. The scanner only posts; it never listens.
  • On Review your app (one scope, zero events), click Create and Install.
  • On the Allow page, choose the channel under Channel for webhook, then Allow. A direct message to yourself also works. Each destination gets its own URL.
  • Close the “is ready” dialog; its Slack CLI steps do not apply. Click Go to App Settings, open Incoming Webhooks under Features, and copy the URL from Webhook URLs for Your Workspace. It starts with https://hooks.slack.com/services/.
  • Turn on notifications for the channel. A channel nobody is notified about is a log, not an alert.

Microsoft Teams (steps as of October 2026)

Teams is reached through a Teams Workflows webhook, which takes an Adaptive Card; use --notify-format teams with that webhook’s URL. The retired Office 365 “Incoming Webhook” connector is not supported.

  • Create the workflow from a durable, shared account rather than a person’s: the workflow belongs to the account that created it, and every card names that owner (see below).
  • In the channel that should receive alerts, open the channel’s … menu and choose Workflows.
  • Choose the template Send webhook alerts to a channel.
  • The next screen shows Untitled workflow: rename it there (or later, from Workflows Home), check Team the channel is in and Channel, then Save.
  • The confirmation screen shows the webhook URL with a copy button. Copy the whole URL — it ends in a long sig= value. Pasting it into a prompt that cuts input at 128 characters (the macOS security … -w prompt is one) stores a URL Teams refuses with ApiVersionInvalid.
  • Turn on notifications for the channel. A channel nobody is notified about is a log, not an alert.

What a reader sees in Teams: the sender is shown as Workflows, not the product — the product’s identity is in the card’s headline and footer — and every card carries Microsoft’s own attribution line naming the workflow’s owner (“… used a Workflow template to send this card”), which the product cannot remove. A Workflows webhook answers 202 Accepted and posts the card afterwards.

Treat the URL as a secret

Anyone who has the URL can post to that channel: it is the only credential. Keep it in a secrets store or in your shell session for the scan, and do not commit it or paste it into tickets. It is passed on the command line, so other users of the same machine can see it in the process list, and a CI log that echoes commands prints it. The scanner records only the destination’s host, never the URL.

03Add the flags to a compliance scan

$ nsauditor-ai scan --host aws --aws-profile prod --plugins all --compliance soc2 \
    --out out/aws-prod \
    --notify-webhook "$SLACK_WEBHOOK_URL" --notify-format slack --notify-severity high

For Teams, the same command with --notify-format teams and your Workflows URL. For a receiver of your own, --notify-format generic or no --notify-format at all.

FlagValuesDefault
--notify-webhook <url>a public https URL; Enterprise; one-shot scans with --compliance, never --watchoff
--notify-formatgeneric, slack, teamsgeneric
--notify-payloadminimal, fullminimal
--notify-severitycritical, high, medium, low, info — the lowest severity of a NEW violation that notifieshigh
--webhook-url <url>a public http or https URL; --watchoff
--alert-payloadfull, minimalfull

--alert-severity (default high; not validated — an unrecognised value is applied as high) sets the lowest severity that makes --watch alert, and --alert-every-cycle alerts every such host on every cycle, the first included; by default only hosts whose scan changed alert.

04When a message is sent, and where the outcome is recorded

  • When the drift could not be compared with a previous run — always, whatever the threshold, so a broken comparison is never silent (trigger drift-not-compared).
  • When a control went to fail (control-to-fail) or lost measurement (control-lost-measurement), whatever the threshold.
  • When a new violation is at or above --notify-severity (new-violation-at-or-above-threshold). A severity that cannot be ranked counts as at or above.
  • A baseline sends nothing: a scan with no previous run in its output directory and no prior pack for this host and framework in --compliance-history. A compared drift that crossed no trigger sends nothing either.

The triggers, as the code names them, in the order they fire: control-to-fail · control-lost-measurement · new-violation-at-or-above-threshold · drift-not-compared

There is one drift file, and at most one notification, per host per framework whose report rendered, so a scan of several hosts or frameworks can send several notifications. Under minimal nothing in the body tells two hosts apart: use full, or one webhook per host, if that matters.

The drift file scan_drift_<fw>.json, written beside the pack, records the outcome in its notification block: the destination’s host only, the format, the payload mode, the threshold, the triggers, and a status.

statusMeaning
not-sentnothing to send; reason is state:baseline or below-threshold
pendingwritten before the send; if it stays pending, the process stopped before the send finished, or the file could not be rewritten after it
senta 2xx answer; statusCode and attemptedAt are recorded
failedno 2xx answer after the attempts, a destination refused before sending, or a body that could not be built; statusCode and error are recorded, and a warning is printed to stderr

attemptedAt is recorded on every send that ran, sent or failed. sent means the receiver ACCEPTED the request: Slack answers 200 once it has posted, while a Teams Workflows webhook answers 202 Accepted and posts the card afterwards. A failed notification never changes the scan’s exit code.

05What arrives in Slack and Teams

The same kind of event — a drift that could not be compared because the previous run had no pack for that framework — delivered to a live Slack channel and a live Teams channel on 2026-10-19 in each payload mode; both products render the same headline and footer. The footer names the schema id, the payload mode and the Enterprise version that wrote the drift. The only difference is the host: minimal leaves it out, full names it, and a drift that was not compared has no lists to add.

--notify-payload minimal

NSAuditor AI drift — hipaa: NOT COMPARED with the previous run (not-comparable: previous-run-has-no-pack-for-framework)
nsauditor.drift-notification/1 · minimal · <version>

--notify-payload full

NSAuditor AI drift — pci-dss on <host>: NOT COMPARED with the previous run (not-comparable: previous-run-has-no-pack-for-framework)
nsauditor.drift-notification/1 · full · <version>

06Build your own receiver — the payload reference

The generic format is the complete payload; the slack and teams formats render a bounded subset of it for those two products. Every key list below is held against the code by the product’s test suite in both directions.

Transport

  • POST with exactly these headers:

    content-type · content-length · host · connection

    content-type is application/json; host and connection are added by Node’s HTTP client. There is no User-Agent, no Authorization and no signature header: the URL is the only credential.
  • The destination is resolved once, before the first attempt, and a private, loopback, link-local or cloud-metadata destination is refused. A host name spelled with such a prefix (10., 192.168., localhost, metadata.google and the like) is refused by its spelling, and a name that does not resolve is refused the same way — never retried, recorded as URL rejected by SSRF guard. Every attempt connects to the address that check resolved, except through a proxy: with NODE_USE_ENV_PROXY and a proxy configured, the request goes to the proxy by name and the proxy resolves it.
  • Each attempt has a 10 s idle timeout. There are up to 3 attempts, 1 s apart. A 2xx is success; a 4xx, 429 included, is not retried; a 5xx, a 3xx and a transport error are retried. Redirects are not followed. A destination that never answers costs about 32 s per notification.

Compared drift — minimal (the default)

schema · payload · producedBy · generatedAt · framework · state · previous · threshold · triggers · counts · counts.toFail · counts.toPass · counts.otherTransitions · counts.lostMeasurement · counts.newViolations · counts.clearedViolations · counts.couldNotMeasure · counts.newEvidenceGaps · counts.goneEvidenceGaps · counts.newAtOrAboveThreshold · controls · controls.toFail · controls.toFail[].id · controls.toFail[].from · controls.toFail[].to · controls.lostMeasurement · controls.lostMeasurement[].id · controls.lostMeasurement[].from · controls.lostMeasurement[].to · controls.toPass · controls.toPass[].id · controls.toPass[].from · controls.toPass[].to · violations · violations.new · violations.new[].controlIds · violations.new[].severity · violations.new[].source · couldNotMeasure · couldNotMeasure[].controlIds · couldNotMeasure[].source · couldNotMeasure[].reason · evidenceGaps · evidenceGaps.new · evidenceGaps.new[].controlIds · evidenceGaps.new[].source

minimal carries no hostname, resource name, account id or finding text. counts are the drift file’s own, complete; the lists carry what triggered, plus the controls that went to pass. Each transition has id, from and to; each violation its controlIds, severity and source (the scanner that produced it); each item that could not be measured its reason code (it carries no severity) — a prior violation this scan could not measure, or a current one that could not be compared with the previous run (identity-basis-changed).

Compared drift — full

schema · payload · producedBy · generatedAt · framework · host · scopeUnit · state · previous · threshold · triggers · counts · counts.toFail · counts.toPass · counts.otherTransitions · counts.lostMeasurement · counts.newViolations · counts.clearedViolations · counts.couldNotMeasure · counts.newEvidenceGaps · counts.goneEvidenceGaps · counts.newAtOrAboveThreshold · controls · controls.toFail · controls.toFail[].id · controls.toFail[].from · controls.toFail[].to · controls.toFail[].title · controls.lostMeasurement · controls.lostMeasurement[].id · controls.lostMeasurement[].from · controls.lostMeasurement[].to · controls.lostMeasurement[].title · controls.toPass · controls.toPass[].id · controls.toPass[].from · controls.toPass[].to · controls.toPass[].title · violations · violations.new · violations.new[].controlIds · violations.new[].severity · violations.new[].source · violations.new[].resource · violations.new[].text · violations.new[].key · couldNotMeasure · couldNotMeasure[].controlIds · couldNotMeasure[].source · couldNotMeasure[].reason · couldNotMeasure[].resource · couldNotMeasure[].text · couldNotMeasure[].detail · couldNotMeasure[].key · evidenceGaps · evidenceGaps.new · evidenceGaps.new[].controlIds · evidenceGaps.new[].source · evidenceGaps.new[].text

full adds host and scopeUnit, each control’s title, and on items the resource, the finding text, the detail of what could not be measured, and key (the finding’s identity in the drift file). A value the scan did not record is null; a key is never omitted. full is scan content leaving to a third party: choose it knowingly.

A drift that was not compared

minimal:

schema · payload · producedBy · generatedAt · framework · state · reason · previous · threshold · triggers

full:

schema · payload · producedBy · generatedAt · framework · host · scopeUnit · state · reason · previous · threshold · triggers

state is not-comparable (a previous run exists and could not be compared; reason is previous-run-did-not-scan-this-host, previous-run-has-no-pack-for-framework or previous-pack-unreadable) or not-computed (the drift itself could not be computed; under minimal the reason is the code drift-not-computed, under full the fault’s message with the webhook URL and the home directory removed). triggers is ["drift-not-compared"].

previous

Under minimal, only which run was compared and when it was generated:

selection · generatedAt

selection is prior-run (the run before this one in the same --out directory) or history-newest (the newest pack in --compliance-history). generatedAt is null on every drift that was not compared — the previous run is named, but no pack of it was read. Under full, previous is the drift file’s own object, passed through unchanged. It is null when no previous run was read.

The watch alert (Community, --watch)

full (the default):

timestamp · host · severity · findingsCount · summary · details · details[].port · details[].protocol · details[].service · details[].description · details[].severity

minimal:

timestamp · payload · severity · findingsCount · summary · details · details[].port · details[].protocol · details[].severity

Per finding: port, protocol (tcp unless the finding carries another, null for a finding with no port) and severity; full adds service and description, and names the host in host and in summary. The watch alert has no schema id.

The schema id

schema names the shape documented here. Read it first, refuse an id you do not know, and ignore keys you do not know. This release makes no compatibility commitment for the drift schemas beyond what this page states.

07The notification’s delivery limits

Delivery limits — what the drift file can measure is listed on the Enterprise page

These are the limits of delivering the notification; for what the drift file itself can and cannot measure, read the drift limits on the Enterprise page. A notification retried after a timeout or a server error can arrive more than once, because every attempt sends the whole body: de-duplicate on the body if that matters to you. A destination that never answers costs about 32 s per framework. A rejection by the receiver — a 4xx, for example a body Slack refuses — is not retried; it is recorded as failed in the drift file and printed to stderr. minimal sends no hostname, resource name, account id or finding text; full sends them.

Know when a control moves between audits

The drift file and its notification ship in NSAuditor AI Enterprise. Read the getting-started guide for a first compliance scan, or the Enterprise overview for what the drift file records.