A mid-size SaaS company fails a customer security review on one line: “you cannot produce an authenticated vulnerability scan of your production estate on demand.” Their old setup was a quarterly unauthenticated network scan from one appliance that could not even see half the cloud accounts, let alone log into a host. The mandate from the new CISO is concrete: every Linux and Windows workload across three AWS accounts, two Azure subscriptions, and one GCP project must be under continuous, authenticated scanning within a sprint, with findings risk-scored, deduplicated against cloud asset inventory, and auto-ticketed to the owning team. This guide builds exactly that with Tenable.io — agent-based authenticated scanning for the hosts you control, cloud connectors for the inventory and the ephemeral fleet you cannot put an agent on, and integrations that turn raw plugins into actioned tickets.
Prerequisites
- A Tenable.io (Tenable Vulnerability Management) container with an Administrator user. You will mint API keys from it.
- Admin or delegated access to create read-only roles in AWS (one or more accounts), Azure (subscription + an Entra app registration), and GCP (a project + service account).
- A bastion or CI runner with
curl,jq, and theaws/az/gcloudCLIs for the connector setup, plusterraform>= 1.6 if you use the IaC path. - Outbound HTTPS (443) from every scanned host to
sensor.cloud.tenable.com(the agent ingress) andcloud.tenable.com(the API). No inbound ports are opened — agents poll out. - A config-management path to push the agent: Ansible for the static VM fleet, plus a baked virtual appliance / golden image so new instances arrive pre-enrolled.
- An Okta or Microsoft Entra ID tenant if you want SSO into the Tenable console (covered in the security notes).
Target topology
Three planes meet in Tenable.io. The agent plane is every host you own running a Nessus Agent that authenticates locally and uploads results outbound — no scan engine reaches into your network, no credentials traverse it. The connector plane is read-only API access into AWS, Azure, and GCP that pulls live asset inventory (instances, their tags, their lifecycle state) so Tenable knows what should exist and can reconcile it against what is reporting. The integration plane pushes the scored output outward: ServiceNow for tickets, CrowdStrike Falcon and your Wiz posture data for cross-correlation, and Datadog/Dynatrace for operational telemetry on the agents themselves. Everything below stands these three up in order.
1. Generate API keys and set up the CLI workspace
Every step here drives the v3 REST API, so start by minting keys. In the Tenable.io console go to Settings → My Account → API Keys → Generate. You get an accessKey and a secretKey shown once. Export them and define a helper so the rest of the guide is copy-paste:
export TIO_ACCESS_KEY="a1b2c3..."
export TIO_SECRET_KEY="d4e5f6..."
export TIO_BASE="https://cloud.tenable.com"
# thin wrapper around the API with auth + JSON headers baked in
tio() {
local method="$1" path="$2"; shift 2
curl -sS -X "$method" "${TIO_BASE}${path}" \
-H "X-ApiKeys: accessKey=${TIO_ACCESS_KEY}; secretKey=${TIO_SECRET_KEY}" \
-H "Accept: application/json" -H "Content-Type: application/json" "$@"
}
# smoke test — should return your container's session
tio GET /session | jq '{name: .name, container: .container_name, permissions}'
If that returns your user object you are authenticated. Do not bake these keys into images or commit them — they are full-power. For automation, store them in HashiCorp Vault and have the runner read them at job start (the KV path pattern is shown in step 8).
2. Create a Nessus Agent group and pull the linking key
Agents register into agent groups, which are the unit you later target with scans. Create one group per environment so a production scan never picks up a dev host:
# create the production agent group
PROD_GROUP_ID=$(tio POST /scanners/null/agent-groups \
-d '{"name":"prod-linux-windows"}' | jq -r '.id')
echo "prod agent group: ${PROD_GROUP_ID}"
# the linking key is what agents present at enrollment (account-wide)
LINK_KEY=$(tio GET /scanners/null/agents/_settings 2>/dev/null | jq -r '.hardware_uuid' )
# linking key lives under Settings → Sensors → Linked Agents in the UI;
# fetch it via the agent config endpoint:
LINK_KEY=$(tio GET /agent-config | jq -r '.key' 2>/dev/null || echo "<copy-from-UI>")
echo "linking key: ${LINK_KEY}"
The linking key is a single account-wide secret. Treat it like the linking credential it is and source it from Vault on the hosts, never a plaintext var in your Ansible repo.
3. Install the Nessus Agent on the host fleet with Ansible
The agent is one package; the work is rolling it out and joining the right group. This Ansible task block installs and links on both RPM and Debian hosts, then pins each host into the prod group. Save as roles/nessus_agent/tasks/main.yml:
- name: Pull linking key from Vault
ansible.builtin.set_fact:
nessus_link_key: "{{ lookup('hashi_vault',
'secret=secret/data/tenable/agent:key') }}"
no_log: true
- name: Install Nessus Agent (Debian/Ubuntu)
ansible.builtin.apt:
deb: "https://www.tenable.com/downloads/api/v2/pages/nessus-agents/files/NessusAgent-latest-ubuntu1604_amd64.deb"
when: ansible_os_family == "Debian"
- name: Install Nessus Agent (RHEL/Amazon Linux)
ansible.builtin.yum:
name: "https://www.tenable.com/downloads/api/v2/pages/nessus-agents/files/NessusAgent-latest-es8.x86_64.rpm"
disable_gpg_check: true
when: ansible_os_family == "RedHat"
- name: Link agent to Tenable.io and join the prod group
ansible.builtin.command:
cmd: >
/opt/nessus_agent/sbin/nessuscli agent link
--key={{ nessus_link_key }}
--groups=prod-linux-windows
--cloud
--name={{ inventory_hostname }}
creates: /opt/nessus_agent/var/nessus/agent.db
no_log: true
- name: Enable and start the agent
ansible.builtin.service:
name: nessusagent
state: started
enabled: true
The --cloud flag points the agent at sensor.cloud.tenable.com instead of an on-prem Nessus Manager; --groups joins at link time so it is scan-eligible immediately. On Windows hosts the equivalent is the MSI plus nessuscli.exe agent link. For auto-scaling fleets, bake the agent into the virtual appliance / AMI and run the link command from cloud-init’s user-data so every new instance self-enrolls within a minute of boot. Verify a host locally:
sudo /opt/nessus_agent/sbin/nessuscli agent status
# expect: "Link status: Connected to cloud.tenable.com:443"
# and "Agent is registered to a manager."
4. Wire the AWS cloud connector for asset inventory
Cloud connectors give Tenable a read-only inventory feed so it can reconcile agents against reality and tag findings with cloud metadata. Tenable assumes a role in your account — never long-lived keys. Create the role with a Terraform stanza (read-only, scoped to the Tenable trust):
data "aws_iam_policy_document" "tenable_trust" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::012615275169:root"] # Tenable's connector account
}
condition {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.tenable_external_id] # from the Tenable UI, per-connector
}
}
}
resource "aws_iam_role" "tenable_connector" {
name = "tenable-io-connector"
assume_role_policy = data.aws_iam_policy_document.tenable_trust.json
}
resource "aws_iam_role_policy_attachment" "ro" {
role = aws_iam_role.tenable_connector.name
policy_arn = "arn:aws:iam::aws:policy/SecurityAudit" # read-only inventory access
}
Then register the connector via the API, passing the role ARN and the external ID Tenable generated:
tio POST /settings/connectors -d '{
"connector": {
"type": "aws",
"name": "aws-prod-account",
"schedule": {"units": "hours", "value": "4"},
"params": {
"role_arn": "arn:aws:iam::111122223333:role/tenable-io-connector",
"external_id": "'"${TENABLE_EXTERNAL_ID}"'",
"regions": ["us-east-1", "eu-west-1"],
"import_ec2": true
}
}
}'
Repeat per account. Within a cycle, Tenable populates the inventory with every EC2 instance plus its tags, AMI, and state — which is how it spots an instance that exists in AWS but has no agent reporting (your coverage gap) and how it suppresses findings on instances AWS reports as terminated.
5. Add Azure and GCP connectors
Same pattern, different identity primitives. Azure uses an Entra app registration with a Reader role on the subscription:
# create the app registration + secret, then grant Reader on the sub
APP_ID=$(az ad app create --display-name "tenable-io-connector" \
--query appId -o tsv)
az ad sp create --id "$APP_ID"
SECRET=$(az ad app credential reset --id "$APP_ID" \
--query password -o tsv)
SUB_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
az role assignment create --assignee "$APP_ID" \
--role "Reader" --scope "/subscriptions/${SUB_ID}"
tio POST /settings/connectors -d '{
"connector": {"type":"azure","name":"azure-prod-sub",
"schedule":{"units":"hours","value":"4"},
"params":{"subscription_id":"'"${SUB_ID}"'","tenant_id":"'"${TENANT_ID}"'",
"client_id":"'"${APP_ID}"'","client_secret":"'"${SECRET}"'"}}}'
GCP uses a service account with roles/viewer, and Tenable authenticates with the JSON key:
gcloud iam service-accounts create tenable-io-connector \
--display-name="Tenable.io connector"
PROJECT_ID=$(gcloud config get-value project)
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:tenable-io-connector@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/viewer"
gcloud iam service-accounts keys create /tmp/tenable-gcp.json \
--iam-account="tenable-io-connector@${PROJECT_ID}.iam.gserviceaccount.com"
# pass the key (base64) into the connector params
KEY_B64=$(base64 -w0 /tmp/tenable-gcp.json)
tio POST /settings/connectors -d '{
"connector":{"type":"gcp","name":"gcp-prod-project",
"schedule":{"units":"hours","value":"4"},
"params":{"service_account_key":"'"${KEY_B64}"'","project_id":"'"${PROJECT_ID}"'"}}}'
shred -u /tmp/tenable-gcp.json # do not leave the key on disk
Store the Azure secret and the GCP key JSON in HashiCorp Vault, not in your shell history — rotate both on the cadence your policy requires.
6. Build and schedule the authenticated agent scan
With agents reporting and inventory flowing, create the scan. Agent scans do not need network credentials — the agent already runs as a privileged local service, so the “authentication” is the host context itself. List the agent template, then create a scan bound to the prod group:
# find the "Advanced Agent Scan" template UUID
TEMPLATE=$(tio GET /editor/scan/templates \
| jq -r '.templates[] | select(.name=="agent_advanced") | .uuid')
# create the scan, targeting the prod agent group, on a daily schedule
SCAN_ID=$(tio POST /scans -d '{
"uuid":"'"${TEMPLATE}"'",
"settings":{
"name":"prod-authenticated-daily",
"enabled":true,
"agent_group_id":['"${PROD_GROUP_ID}"'],
"scan_time_window":180,
"launch":"DAILY",
"starttime":"20260611T020000",
"rrules":"FREQ=DAILY;INTERVAL=1",
"timezone":"Asia/Kolkata"
}
}' | jq -r '.scan.id')
echo "scan id: ${SCAN_ID}"
scan_time_window (minutes) is the key agent setting: agents check in within that window and run on their own clock, so 4,000 hosts do not stampede a single engine. There is no scan engine to size — the work runs on each host. Launch a one-off run now to seed results:
tio POST /scans/${SCAN_ID}/launch | jq
7. Validate coverage and pull risk-scored findings
Validation is two questions: are agents healthy, and do findings carry VPR (Vulnerability Priority Rating, Tenable’s risk score blending CVSS with threat intel)?
# 1) agent health — anything not "on" or last-seen stale is a coverage hole
tio GET /scanners/null/agents?limit=5000 \
| jq -r '.agents[] | [.name, .status,
(now - .last_connect | floor | tostring + "s ago")] | @tsv' \
| sort
# 2) reconcile: cloud assets WITHOUT an agent (the gap you must close)
tio GET "/assets" | jq -r '
.assets[] | select(.has_agent==false and .sources[].name=="AWS")
| .fqdn[0] // .ipv4[0]'
# 3) top risk-scored findings across the estate, by VPR
tio POST /workbenches/vulnerabilities -d '{
"filters":[{"filter":"severity","quality":"gte","value":"high"}],
"sort":[{"order":"desc","property":"vpr_score"}]
}' | jq -r '.vulnerabilities[:15][]
| [(.vpr_score|tostring), .plugin_name, (.count|tostring)] | @tsv'
A green result is: every expected host in the agent list with a recent last_connect, an empty (or known-and-shrinking) no-agent list, and findings sorted by VPR so the team patches the genuinely exploitable issues first rather than chasing raw CVSS 10s with no known exploit.
8. Push findings into ServiceNow and the wider tool fabric
Raw findings are noise until they become owned work. Use Tenable’s native ServiceNow connector (or the API export) to open a change/incident per high-VPR finding on the asset’s owning team, keyed off the cloud tags the connector imported in steps 4–5:
# export high-VPR findings as the source feed for ticketing/SIEM
EXPORT_UUID=$(tio POST /vulns/export -d '{
"filters":{"severity":["high","critical"],"vpr_score":{"gte":7.0}},
"num_assets":500
}' | jq -r '.export_uuid')
# poll until FINISHED, then download chunks
tio GET /vulns/export/${EXPORT_UUID}/status | jq '.status'
tio GET /vulns/export/${EXPORT_UUID}/chunks/1 > /tmp/tio-high-vpr.json
Where each tool plugs in:
- ServiceNow — receives a ticket per high-VPR finding, routed by the asset’s
ownertag; the SLA clock and the closure record live here, giving audit a paper trail the failed customer review demanded. - CrowdStrike Falcon — its EDR sensor inventory is cross-referenced with the agent list so a host with Falcon but no Nessus Agent (or vice versa) is flagged as partial coverage.
- Wiz / Wiz Code — Wiz’s agentless cloud posture and Wiz Code’s IaC/repo scanning sit alongside Tenable: Wiz finds the misconfiguration and the exposure path, Tenable supplies the authenticated in-host CVE detail, and you correlate the two for true attack-path priority.
- Datadog / Dynatrace — ingest the agent’s health metrics and the scan job telemetry so an agent that stops checking in pages the on-call before it becomes a silent blind spot; Dynatrace’s host map confirms the agent process is alive on each node.
- Jenkins / GitHub Actions / Argo CD — the export step above runs as a pipeline stage so a build fails when a deployable image carries a critical finding; Argo CD blocks the sync of a workload whose image is over the VPR threshold.
- Akamai — for the internet-facing tier, Akamai’s WAF telemetry is correlated with Tenable’s findings on the same origin so an actively-probed vulnerability jumps the patch queue.
- Moodle — the security team publishes the agent-onboarding runbook and the “read your Tenable findings” course on Moodle so every workload owner knows how to act on a ticket, which is what actually moves the patch-rate metric.
Run the whole steps 1–8 sequence from a pipeline (Jenkins or GitHub Actions) that reads the API keys from HashiCorp Vault at job start:
# in CI: fetch keys from Vault, never from the environment definition
export TIO_ACCESS_KEY=$(vault kv get -field=access_key secret/tenable/api)
export TIO_SECRET_KEY=$(vault kv get -field=secret_key secret/tenable/api)
Validation checklist
nessuscli agent statuson a sample host returns Connected and registered to a manager.- The agent list shows every expected host with
last_connectinside the last scan window; no unexpectedoff/initstates. - Each cloud connector’s last sync (
tio GET /settings/connectors) is recent andlast_sync_statusissuccess. - The no-agent reconciliation query returns only known exceptions (e.g. managed PaaS you cannot agent).
- A workbench query returns findings populated with
vpr_score, proving risk scoring — not just CVSS — is flowing. - A test high-VPR finding produced a ServiceNow ticket assigned to the correct owner.
Rollback / teardown
Tear down in reverse dependency order so nothing keeps polling or holding access:
# 1) disable then delete the scheduled scan
tio PUT /scans/${SCAN_ID} -d '{"settings":{"enabled":false}}'
tio DELETE /scans/${SCAN_ID}
# 2) unlink agents (run on each host, e.g. via the same Ansible play)
sudo /opt/nessus_agent/sbin/nessuscli agent unlink
sudo apt-get remove --purge nessusagent -y # or: yum remove NessusAgent
# 3) delete the agent group
tio DELETE /scanners/null/agent-groups/${PROD_GROUP_ID}
# 4) delete each cloud connector (stops the inventory pulls)
for C in $(tio GET /settings/connectors | jq -r '.connectors[].id'); do
tio DELETE /settings/connectors/${C}
done
Then revoke cloud access at the source: destroy the AWS tenable-io-connector role (or terraform destroy the connector module), delete the Azure app registration (az ad app delete --id "$APP_ID"), and remove the GCP service account (gcloud iam service-accounts delete ...). Finally delete the API keys in the Tenable UI so the orphaned credential cannot be replayed.
Common pitfalls
- Outbound 443 blocked. Agents poll out to
sensor.cloud.tenable.com; if egress is filtered they link but never upload. Symptom: agent showsConnectedlocally butlast_connectnever advances in the console. Fix the egress allow-list or proxy (nessuscli agentsupports--proxy-host). - Linking key vs API key confusion. The account-wide linking key joins agents; the per-user API key drives the REST calls. They are not interchangeable — using one for the other fails silently with a 403.
- Connector role too broad. Resist
AdministratorAccess.SecurityAudit/Reader/roles/viewerare sufficient for inventory; anything more is an audit finding waiting to happen. - Scan window too tight. A 30-minute
scan_time_windowagainst thousands of agents leaves slow or asleep hosts unscanned. Size it to your fleet’s check-in spread (90–180 min is typical). - Treating VPR like CVSS. A CVSS 10 with no known exploit is lower VPR than a CVSS 7 being actively exploited. Sort and ticket on VPR or you will burn the team on the wrong fixes.
- Ephemeral hosts never enrolled. Auto-scaled instances that boot and die in minutes will not get an Ansible run — they must self-link from user-data in the golden image, or the cloud connector inventory will flag them as perpetually uncovered.
Security notes
Lock the console behind SSO: federate Okta or Microsoft Entra ID to Tenable.io over SAML and enforce conditional access and MFA, so console access follows your central identity policy rather than local Tenable passwords. Source every secret — API keys, the agent linking key, the Azure client secret, the GCP key JSON — from HashiCorp Vault with short leases and scheduled rotation; nothing sensitive belongs in an Ansible repo, a CI variable block, or a baked image layer. Keep connector IAM strictly read-only (SecurityAudit, Reader, roles/viewer) and scope each external-ID trust to a single connector so a leaked role ARN is useless without it. The agent model is itself a security win: no inbound ports, no scan engine holding host credentials, and no secrets crossing your network — authentication happens locally on each host.
Cost notes
Tenable.io licenses per asset under management, so cost tracks the inventory the connectors discover, not scan frequency — there is no penalty for scanning daily instead of weekly, which makes continuous authenticated scanning effectively free once the asset is licensed. The real lever is keeping the asset count honest: let the cloud connectors age out terminated instances so you are not paying for ghosts, exclude short-lived CI/build hosts from licensed scanning where policy allows, and reconcile the licensed-asset count against the cloud inventory monthly. Agent compute is negligible (the agent is idle until its window), so the only operational cost is the read-only API calls the connectors make, which sit far inside every provider’s free inventory-read tier.