01Two senders
| Drift notification | Watch alert | |
|---|---|---|
| Edition | Enterprise | Community |
| Fires on | a one-shot scan with --compliance, when its drift notifies (section 04) — at most one notification per host per framework whose report rendered | a --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 scheme | https only | http or https |
| Formats | generic, slack, teams | one JSON shape |
| Schema id | nsauditor.drift-notification/1 | none |
| Outcome recorded | scan_drift_<fw>.json → notification | printed 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/appsand 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-webhookand 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 macOSsecurity … -wprompt is one) stores a URL Teams refuses withApiVersionInvalid. - 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.
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.
| Flag | Values | Default |
|---|---|---|
--notify-webhook <url> | a public https URL; Enterprise; one-shot scans with --compliance, never --watch | off |
--notify-format | generic, slack, teams | generic |
--notify-payload | minimal, full | minimal |
--notify-severity | critical, high, medium, low, info — the lowest severity of a NEW violation that notifies | high |
--webhook-url <url> | a public http or https URL; --watch | off |
--alert-payload | full, minimal | full |
--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.
status | Meaning |
|---|---|
not-sent | nothing to send; reason is state:baseline or below-threshold |
pending | written before the send; if it stays pending, the process stopped before the send finished, or the file could not be rewritten after it |
sent | a 2xx answer; statusCode and attemptedAt are recorded |
failed | no 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.
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>
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
POSTwith exactly these headers:content-type·content-length·host·connectioncontent-typeisapplication/json;hostandconnectionare added by Node’s HTTP client. There is noUser-Agent, noAuthorizationand 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.googleand the like) is refused by its spelling, and a name that does not resolve is refused the same way — never retried, recorded asURL rejected by SSRF guard. Every attempt connects to the address that check resolved, except through a proxy: withNODE_USE_ENV_PROXYand 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
2xxis success; a4xx,429included, is not retried; a5xx, a3xxand 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
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.