Security Multi-cloud

Set Up Tenable.io Vulnerability Scanning with Nessus Agents and Cloud Connectors

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

Target topology

Set Up Tenable.io Vulnerability Scanning with Nessus Agents and Cloud Connectors — 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:

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

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

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.

Tenable.ioNessusVulnerability ManagementCloud SecurityServiceNowTerraform
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading