back to the project

Tenable Hardening Dashboard: System Guide

Technical documentation I wrote for the Splunk and Tenable dashboard
Splunk SPL / tstats Data model Tenable VM
About this document This is the operating guide I wrote alongside the dashboard. It specifies the data model the searches depend on, every SPL search behind the panels, the input and field-extraction configuration, and the procedure for replacing the synthetic dataset with a live Tenable feed. It is written so someone else could take the build into production without me.

01Purpose and panel order

The dashboard presents monthly hardening and compliance results in a deliberate order:

  1. Monthly compliance trend
  2. Hosts, latest compliance, and latest critical findings
  3. High-risk non-compliant items, always one row per host and control
  4. Host hardening items, showing every latest compliant and non-compliant result
  5. Supporting host summary and compliance-by-host chart

Two dashboard tokens drive the filtering: $os_filter$ (selected operating system, or *) and $host_filter$ (selected hostname, or *). The time window runs -13mon@mon through now.

02Required data model

The searches read indexed CSV fields via tstats. These exact field names must be preserved when the synthetic data is replaced with a live feed.

tenable:hardening — one host summary per scan

FieldMeaningExample
scan_dateScan date, used as _time2026-07-15
scan_idUnique scan identifiermonthly-cis-202607
hostnameNormalised host namerhel8-web01
ip_addressHost IP at scan time10.20.5.14
operating_systemNormalised OS labelRHEL 8
compliance_pctPassed checks / total checks × 10091.4
critical_findingsCritical failed checks2
high_findingsHigh failed checks7
medium_findingsMedium failed checks14
passed_checksPassed audit checks137
failed_checksFailed audit checks23
scan_policyAudit or policy nameCIS RHEL 8 Level 1
data_sourceSource systemTenable VM

tenable:highrisk — one host/control result per scan

FieldMeaningExample
scan_dateScan date, used as _time2026-07-15
scan_idUnique scan identifiermonthly-cis-202607
hostnameNormalised host namerhel8-web01
ip_addressHost IP10.20.5.14
operating_systemNormalised OS labelRHEL 8
control_idStable audit/plugin/control IDLNX-PRIV-001
controlHuman-readable check title/bin/bash ownership and permissions
severityCritical or HighCritical
statusExactly Compliant or Non-compliantNon-compliant
expected_settingSecure target configurationroot:root with mode 0755
risk_reasonWhy failure could enable administrative accessWritable shell may allow root code execution
data_sourceSource systemTenable VM

Operating-system values are normalised to: Windows Server 2016, Windows Server 2019, Windows Server 2022, RHEL 7, RHEL 8, RHEL 9 and OEL 8.

03Inputs and timestamp extraction

Input configuration lives in tenable_hardening_app/default/inputs.conf, with field extraction in props.conf:

[tenable:hardening]
INDEXED_EXTRACTIONS = csv
HEADER_FIELD_LINE_NUMBER = 1
TIMESTAMP_FIELDS = scan_date
TIME_FORMAT = %Y-%m-%d
TZ = Australia/Adelaide
SHOULD_LINEMERGE = false

[tenable:highrisk]
INDEXED_EXTRACTIONS = csv
HEADER_FIELD_LINE_NUMBER = 1
TIMESTAMP_FIELDS = scan_date
TIME_FORMAT = %Y-%m-%d
TZ = Australia/Adelaide
SHOULD_LINEMERGE = false

In production, write normalised events to the monitored CSV paths, send them through the Splunk HTTP Event Collector with the same sourcetypes, or adapt the searches to the sourcetypes created by the official Tenable Add-on for Splunk.

04The SPL behind each panel

Operating-system selector

| tstats count where index=main sourcetype="tenable:hardening"
  by operating_system
| sort operating_system

Host selector

| tstats count where index=main sourcetype="tenable:highrisk"
  operating_system="$os_filter$"
  by hostname
| sort hostname

Monthly compliance trend

| tstats avg(compliance_pct) as "Compliance %"
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"
  by _time span=1mon operating_system
| xyseries _time operating_system "Compliance %"

Host count

| tstats dc(hostname) as hosts
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"

Latest compliance

| tstats avg(compliance_pct) as compliance
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"
  by _time span=1mon
| sort - _time
| head 1
| eval compliance=round(compliance,1)."%"

Latest critical findings

| tstats sum(critical_findings) as critical
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"
  by _time span=1mon
| sort - _time
| head 1

High-risk non-compliant items

| tstats latest(scan_date) as "Scan Date"
  latest(status) as Status
  latest(expected_setting) as "Expected Setting"
  latest(risk_reason) as "Why This Is High Risk"
  latest(severity) as Severity
  where index=main sourcetype="tenable:highrisk"
  operating_system="$os_filter$" hostname="$host_filter$"
  by hostname operating_system control_id control
| where Status="Non-compliant"
| eval severity_order=case(Severity="Critical",1,Severity="High",2,true(),3)
| sort severity_order operating_system hostname control
| fields - severity_order
| rename hostname as Host operating_system as "Operating System"
  control_id as "Control ID" control as "High-risk Item"

Host hardening items, latest result per item

| tstats latest(scan_date) as "Scan Date"
  latest(status) as "Scan Result"
  latest(severity) as Severity
  latest(expected_setting) as "Expected Setting"
  latest(risk_reason) as Risk
  where index=main sourcetype="tenable:highrisk"
  operating_system="$os_filter$" hostname="$host_filter$"
  by hostname operating_system control_id control
| eval result_order=case('Scan Result'="Non-compliant",1,true(),2),
       severity_order=case(Severity="Critical",1,Severity="High",2,true(),3)
| sort result_order severity_order hostname control
| fields - result_order severity_order
| rename hostname as Host operating_system as "Operating System"
  control_id as "Control ID" control as "Hardening Item"

Latest host summary

| tstats latest(ip_address) as "IP Address"
  latest(compliance_pct) as "Compliance %"
  latest(critical_findings) as Critical
  latest(high_findings) as High
  latest(medium_findings) as Medium
  latest(passed_checks) as Passed
  latest(failed_checks) as Failed
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"
  by hostname operating_system
| sort operating_system hostname
| rename hostname as Host operating_system as "Operating System"

Compliance by host

| tstats latest(compliance_pct) as "Compliance %"
  where index=main sourcetype="tenable:hardening"
  operating_system="$os_filter$"
  by hostname
| sort - "Compliance %"

05Moving from synthetic to live Tenable data

  1. Install and configure the official Tenable Add-on for Splunk, or build an API collector using a restricted Tenable service account.
  2. Export asset data plus vulnerability and compliance findings. Preserve open, reopened and fixed/passed states so the latest status stays accurate.
  3. Convert each scan to the two schemas above. Use a stable asset UUID internally where available, even if hostname remains the dashboard label.
  4. Calculate the host summary once per completed scan. Do not derive compliance from only critical and high findings; include every applicable audit check.
  5. Map Tenable audit results consistently: PASSED to Compliant, FAILED to Non-compliant. Decide explicitly how WARNING, ERROR and manual-review results should appear.
  6. Mark privilege-escalation controls as High or Critical using an approved control catalogue, not title keyword matching alone.
  7. Send events to a production index such as tenable, then change index=main in every search.
  8. Validate event counts, latest scan dates, OS normalisation and one known host before releasing the dashboard to users.
  9. Store API keys in Splunk's encrypted credential storage or a secrets manager. Never put keys in dashboard XML, scripts or CSV files.

Tenable Vulnerability Management uses asynchronous export jobs for assets, vulnerabilities and compliance data, so the collector has to poll for job completion rather than expecting a synchronous response.

06Production validation searches

Run these after cutting over, before the dashboard is released:

| tstats count min(_time) as first max(_time) as last
  where index=tenable sourcetype="tenable:hardening"
  by operating_system
| convert ctime(first) ctime(last)
| tstats latest(status) as status
  where index=tenable sourcetype="tenable:highrisk"
  by hostname operating_system control_id control
| stats count by operating_system status

Hosts whose most recent scan is older than 35 days:

| tstats latest(scan_date) as scan_date
  where index=tenable sourcetype="tenable:highrisk"
  by hostname
| where strptime(scan_date,"%Y-%m-%d") < relative_time(now(),"-35d")
| rename hostname as "Host with stale scan" scan_date as "Latest scan"
Note on the data The lab runs on twelve months of synthetic, clearly-labelled Tenable-style scan data generated by a Python script, so every panel and trend is fully populated without touching a real scanner. All host names, IPs and results in this document are from that synthetic dataset.

back to the project