BIND DNSSEC Validation Disabled
named.conf sets dnssec-validation no, so the resolver accepts forged answers for signed zones.
- Why It Matters
- DNSSEC validation lets a recursive resolver reject spoofed records for zones that are signed, which blocks cache poisoning of those names. BIND 9.16 and newer default to auto with the built-in root trust anchor, so an explicit no is a deliberate weakening.
- How It Detects
- Reads named.conf and named.conf.options for dnssec-validation. Fails on no . Absent passes with a note (the default is auto on supported releases); auto or yes passes.
- How to Fix
- Set dnssec-validation auto; in options (remove any no ), run named-checkconf and rndc reconfig. Authoritative-only servers with recursion off may leave it unset.
- How to Verify
dig @127.0.0.1 dnssec-failed.org a should return SERVFAIL, and a signed name should show the ad flag.- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N- Control Category
- Data Services: CIS Controls v8 3, 4, 12; NIST CSF 2.0 PR.DS, PR.PS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 4.1
- NIST CSF 2.0 Subcategory
- PR.PS-01
- CWE
- CWE-347, CWE-350
- OWASP Top 10
- A08:2021
- NIST SP 800-53 Rev. 5
- SC-20, SC-21
- Benchmark Topic
- DNS resolver validation (topic only, no benchmark text)
- References
- BIND 9 Administrator Reference Manual, dnssec-validation
NIST SP 800-81 Rev. 2, Secure Domain Name System Deployment Guide
Source Code
The complete file that defines this check. Run it with openvapt scan --only NET-DNS-002, or read it offline with openvapt explain NET-DNS-002.
internal/checks/network/data_depth.go · Lines 1-526View on GitHub at This Commit
| 1 | package network |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | |
| 7 | "github.com/openvapt/openvapt/internal/check" |
| 8 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 9 | "github.com/openvapt/openvapt/internal/sys" |
| 10 | ) |
| 11 | |
| 12 | // NET-DB-014 to NET-DB-023: database hardening depth for MySQL or MariaDB, |
| 13 | // PostgreSQL and Redis, read from option files only. Nothing connects to a |
| 14 | // database or runs a client. NET-DB-002 to NET-DB-005 already cover network |
| 15 | // binding, trust authentication, local_infile and skip-grant-tables, so |
| 16 | // those signals are not repeated here. |
| 17 | |
| 18 | // depthRule is a configuration-file check with explicit metadata. Unlike |
| 19 | // configRule it carries its own standards and effort, and it distinguishes |
| 20 | // "service absent" (Skip) from "service installed but unreadable" |
| 21 | // (Incomplete) by probing marker paths. |
| 22 | type depthRule struct { |
| 23 | id, title string |
| 24 | sev check.Severity |
| 25 | cat check.Category |
| 26 | cvss, summary, rationale, detection, fix string |
| 27 | verify string |
| 28 | patterns []string |
| 29 | markers []string // folders or binaries that mean the service is installed |
| 30 | refs []check.Ref |
| 31 | std check.Standards |
| 32 | evaluate func(fs []collect.ConfigFile) (bad bool, ev []string) |
| 33 | // evaluateSys, when set, replaces evaluate for rules that must also read |
| 34 | // the system (for example the crypto library floor). A non-empty pass |
| 35 | // string replaces passMsg so the Pass can say why. |
| 36 | evaluateSys func(ctx context.Context, s sys.System, fs []collect.ConfigFile) (bad bool, ev []string, pass string) |
| 37 | passMsg string |
| 38 | // evaluateFull, when set, replaces both and may also report that the |
| 39 | // answer depends on something that could not be observed (incomplete). |
| 40 | evaluateFull func(ctx context.Context, s sys.System, fs []collect.ConfigFile) (bad bool, ev []string, pass, incomplete string) |
| 41 | } |
| 42 | |
| 43 | func installed(s sys.System, markers []string) bool { |
| 44 | for _, m := range markers { |
| 45 | if _, err := s.Stat(m); err == nil { |
| 46 | return true |
| 47 | } |
| 48 | } |
| 49 | return false |
| 50 | } |
| 51 | |
| 52 | func registerDepth(r depthRule) { |
| 53 | check.Register(&check.Check{ |
| 54 | ID: r.id, Title: r.title, Domain: check.DomainNetwork, Severity: r.sev, Category: r.cat, Effort: check.EffortLow, CVSS: r.cvss, |
| 55 | Summary: r.summary, Rationale: r.rationale, Detection: r.detection, Fix: r.fix, Verify: r.verify, Refs: r.refs, Standards: r.std, |
| 56 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 57 | fs := collect.ReadConfigs(ctx, s, r.patterns...) |
| 58 | target := strings.Join(r.patterns, ", ") |
| 59 | if len(fs) == 0 { |
| 60 | if installed(s, r.markers) { |
| 61 | return []check.Result{check.IncompleteR(target, "The service appears installed but its configuration could not be read")} |
| 62 | } |
| 63 | return []check.Result{check.SkipR(target, "Service configuration was not found")} |
| 64 | } |
| 65 | var bad bool |
| 66 | var ev []string |
| 67 | var pass string |
| 68 | if r.evaluateFull != nil { |
| 69 | var inc string |
| 70 | bad, ev, pass, inc = r.evaluateFull(ctx, s, fs) |
| 71 | if inc != "" && !bad { |
| 72 | return []check.Result{check.IncompleteR(fs[0].Path, inc, ev...)} |
| 73 | } |
| 74 | } else if r.evaluateSys != nil { |
| 75 | bad, ev, pass = r.evaluateSys(ctx, s, fs) |
| 76 | } else { |
| 77 | bad, ev = r.evaluate(fs) |
| 78 | } |
| 79 | if bad { |
| 80 | return []check.Result{check.FailR(fs[0].Path, r.summary, ev...)} |
| 81 | } |
| 82 | if anyIncomplete(fs) { |
| 83 | return []check.Result{check.IncompleteR(fs[0].Path, "Configuration input was truncated; the setting could not be confirmed")} |
| 84 | } |
| 85 | msg := r.passMsg |
| 86 | if pass != "" { |
| 87 | msg = pass |
| 88 | } |
| 89 | if msg == "" { |
| 90 | msg = "The hardening setting is present" |
| 91 | } |
| 92 | return []check.Result{check.PassR(fs[0].Path, msg, ev...)} |
| 93 | }, |
| 94 | }) |
| 95 | } |
| 96 | |
| 97 | var ( |
| 98 | mysqlFiles = []string{"/etc/mysql/my.cnf", "/etc/mysql/mysql.conf.d/*.cnf", "/etc/mysql/mariadb.conf.d/*.cnf", "/etc/mysql/conf.d/*.cnf", "/etc/my.cnf", "/etc/my.cnf.d/*.cnf"} |
| 99 | mysqlMarkers = []string{"/etc/mysql", "/etc/my.cnf.d", "/usr/sbin/mysqld", "/usr/sbin/mariadbd"} |
| 100 | pgConf = []string{"/etc/postgresql/*/main/postgresql.conf", "/var/lib/pgsql/data/postgresql.conf", "/var/lib/pgsql/*/data/postgresql.conf"} |
| 101 | pgHBA = []string{"/etc/postgresql/*/main/pg_hba.conf", "/var/lib/pgsql/data/pg_hba.conf", "/var/lib/pgsql/*/data/pg_hba.conf"} |
| 102 | pgMarkers = []string{"/etc/postgresql", "/var/lib/pgsql", "/usr/lib/postgresql"} |
| 103 | redisFiles = []string{"/etc/redis/redis.conf", "/etc/redis.conf"} |
| 104 | redisMarkers = []string{"/etc/redis", "/usr/bin/redis-server"} |
| 105 | |
| 106 | refMySQLSec = check.Ref{Title: "MySQL 8.4 Reference Manual, Security Guidelines", URL: "https://dev.mysql.com/doc/refman/8.4/en/security-guidelines.html"} |
| 107 | refMySQLOpts = check.Ref{Title: "MySQL 8.4 Reference Manual, Server System Variables", URL: "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html"} |
| 108 | refPGAuth = check.Ref{Title: "PostgreSQL, Authentication Methods", URL: "https://www.postgresql.org/docs/current/auth-methods.html"} |
| 109 | refPGConn = check.Ref{Title: "PostgreSQL, Connections and Authentication", URL: "https://www.postgresql.org/docs/current/runtime-config-connection.html"} |
| 110 | refPGSSL = check.Ref{Title: "PostgreSQL, Secure TCP/IP Connections with SSL", URL: "https://www.postgresql.org/docs/current/ssl-tcp.html"} |
| 111 | refPGLog = check.Ref{Title: "PostgreSQL, Error Reporting and Logging", URL: "https://www.postgresql.org/docs/current/runtime-config-logging.html"} |
| 112 | refRedisSec = check.Ref{Title: "Redis Security", URL: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/"} |
| 113 | refRedisACL = check.Ref{Title: "Redis ACL", URL: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/"} |
| 114 | refNISTSC8 = check.Ref{Title: "NIST SP 800-53 Rev. 5, SC-8 Transmission Confidentiality and Integrity", URL: "https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final"} |
| 115 | refNISTDigest = check.Ref{Title: "NIST SP 800-63B, Digital Identity Guidelines: Authentication", URL: "https://pages.nist.gov/800-63-3/sp800-63b.html"} |
| 116 | ) |
| 117 | |
| 118 | // mysqlValue returns every value of key from the [mysqld] or [mariadb] |
| 119 | // sections, or unsectioned, accepting both dash and underscore spellings. |
| 120 | func mysqlValues(fs []collect.ConfigFile, key string) []string { |
| 121 | // Only server sections count: ParseKV also records every key without |
| 122 | // its section, which would let a [client] setting satisfy a [mysqld] |
| 123 | // requirement. |
| 124 | var keys []string |
| 125 | for _, k := range []string{key, strings.ReplaceAll(key, "_", "-"), strings.ReplaceAll(key, "-", "_")} { |
| 126 | keys = append(keys, "mysqld."+k, "mariadb."+k, "server."+k) |
| 127 | } |
| 128 | return values(fs, keys...) |
| 129 | } |
| 130 | |
| 131 | func mysqlSet(fs []collect.ConfigFile, key string) bool { return len(mysqlValues(fs, key)) > 0 } |
| 132 | |
| 133 | func anyMySQL(fs []collect.ConfigFile, key string, want func(string) bool) bool { |
| 134 | for _, v := range mysqlValues(fs, key) { |
| 135 | if want(v) { |
| 136 | return true |
| 137 | } |
| 138 | } |
| 139 | return false |
| 140 | } |
| 141 | |
| 142 | func mysqlEvidence(fs []collect.ConfigFile, keys ...string) []string { |
| 143 | var all []string |
| 144 | for _, k := range keys { |
| 145 | for _, kk := range []string{k, strings.ReplaceAll(k, "_", "-")} { |
| 146 | all = append(all, "mysqld."+kk, "mariadb."+kk, "server."+kk) |
| 147 | } |
| 148 | } |
| 149 | return evidence(fs, all...) |
| 150 | } |
| 151 | |
| 152 | // pgValue returns the first effective value of a postgresql.conf setting, |
| 153 | // stripped of quotes. postgresql.conf lets later lines override earlier |
| 154 | // ones, so the last value wins. |
| 155 | func pgValue(fs []collect.ConfigFile, key string) (string, bool) { |
| 156 | v := values(fs, key) |
| 157 | if len(v) == 0 { |
| 158 | return "", false |
| 159 | } |
| 160 | return strings.Trim(strings.TrimSpace(v[len(v)-1]), `'"`), true |
| 161 | } |
| 162 | |
| 163 | var isOn = func(v string) bool { v = strings.ToLower(v); return v == "on" || v == "1" || v == "true" || v == "yes" } |
| 164 | var isOff = func(v string) bool { |
| 165 | v = strings.ToLower(v) |
| 166 | return v == "off" || v == "0" || v == "false" || v == "no" |
| 167 | } |
| 168 | |
| 169 | func init() { |
| 170 | dbStd := func(cwe []string, nist ...string) check.Standards { |
| 171 | return check.Standards{CWE: cwe, OWASP: []string{"A05:2021"}, ASVS: []string{"14.1.3"}, NIST: nist, CISTopic: "Database server hardening"} |
| 172 | } |
| 173 | |
| 174 | // MySQL / MariaDB |
| 175 | registerDepth(depthRule{ |
| 176 | id: "NET-DB-014", title: "MySQL Symbolic Links Allowed for Tables", sev: check.Medium, cat: check.CatData, |
| 177 | cvss: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L", |
| 178 | summary: "MySQL or MariaDB does not disable symbolic links for table files (skip-symbolic-links or symbolic-links=0).", |
| 179 | rationale: "With symlinks allowed, a user who can create tables can point table files at paths outside the data directory and read or overwrite them with the server's privileges. The MySQL manual recommends disabling the feature unless it is needed.", |
| 180 | detection: "Parses the MySQL and MariaDB option files for skip-symbolic-links, symbolic-links or skip_symbolic_links under [mysqld]. When none disables the feature, reads the server flavour and version (the MariaDB binary or option folder, installed packages in the dpkg status file or the allow-listed rpm -qa): fails on MariaDB and MySQL before 8.0, passes with a note on MySQL 8.0 and later (off by default), and reports Incomplete when the version cannot be read.", |
| 181 | fix: "Add symbolic-links=0 (or skip-symbolic-links ) under [mysqld] and restart the service in a maintenance window.", |
| 182 | verify: "SHOW VARIABLES LIKE 'have_symlink'; from an administrator session should print DISABLED.", |
| 183 | patterns: mysqlFiles, markers: mysqlMarkers, |
| 184 | refs: []check.Ref{{Title: "MySQL 8.4 Reference Manual, Using Symbolic Links", URL: "https://dev.mysql.com/doc/refman/8.4/en/symbolic-links.html"}, refMySQLSec}, |
| 185 | std: dbStd([]string{"CWE-59", "CWE-732"}, "CM-6", "AC-6"), |
| 186 | evaluateFull: func(ctx context.Context, s sys.System, fs []collect.ConfigFile) (bool, []string, string, string) { |
| 187 | ok := mysqlSet(fs, "skip-symbolic-links") || anyMySQL(fs, "symbolic-links", isOff) |
| 188 | ev := mysqlEvidence(fs, "skip_symbolic_links", "symbolic_links") |
| 189 | if ok { |
| 190 | return false, ev, "", "" |
| 191 | } |
| 192 | if len(mysqlValues(fs, "symbolic-links")) > 0 { |
| 193 | return true, ev, "", "" // explicitly enabled |
| 194 | } |
| 195 | fl := detectMySQLFlavor(ctx, s) |
| 196 | if fl.modernMySQL() { |
| 197 | return false, append(ev, "server is "+fl.String()), "No symbolic-links setting, but MySQL 8.0 and later disable symbolic links by default (the option is removed in 8.4)", "" |
| 198 | } |
| 199 | if !fl.known() { |
| 200 | return false, append(ev, "no symbolic-links setting"), "", "No symbolic-links setting, and the server flavour and version could not be read: MariaDB and MySQL before 8.0 allow symbolic links by default, MySQL 8.0 and later do not" |
| 201 | } |
| 202 | return true, append(ev, "no symbolic-links setting; "+fl.String()+" allows symbolic links by default"), "", "" |
| 203 | }, |
| 204 | }) |
| 205 | |
| 206 | registerDepth(depthRule{ |
| 207 | id: "NET-DB-015", title: "MySQL secure_file_priv Not Restricted", sev: check.Medium, cat: check.CatData, |
| 208 | cvss: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:L/A:N", |
| 209 | summary: "secure_file_priv is not set, or is set to an empty value, so LOAD DATA and SELECT ... INTO OUTFILE can read and write anywhere the server process can.", |
| 210 | rationale: "File import and export statements run with the database server's filesystem rights. Limiting them to one folder, or disabling them with NULL, stops an injected query from reading /etc files or dropping a web shell into the document root.", |
| 211 | detection: "Parses the option files for secure_file_priv (or secure-file-priv) under [mysqld]. NULL or a folder path passes; an explicit empty value fails. When the setting is absent, reads the server flavour and version (the MariaDB binary or option folder, installed packages in the dpkg status file or the allow-listed rpm -qa): fails on MariaDB and MySQL before 8.0, passes with a note on packaged MySQL 8.0 and later (restricted by default), and reports Incomplete when the version cannot be read.", |
| 212 | fix: "Add secure_file_priv = NULL (no file operations) or secure_file_priv = /var/lib/mysql-files under [mysqld] and restart in a maintenance window.", |
| 213 | verify: "SHOW VARIABLES LIKE 'secure_file_priv'; should print NULL or the chosen folder.", |
| 214 | patterns: mysqlFiles, markers: mysqlMarkers, |
| 215 | refs: []check.Ref{{Title: "MySQL 8.4 Reference Manual, secure_file_priv", URL: "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_secure_file_priv"}, refMySQLSec}, |
| 216 | std: dbStd([]string{"CWE-73", "CWE-250"}, "CM-6", "AC-6", "SI-10"), |
| 217 | evaluateFull: func(ctx context.Context, s sys.System, fs []collect.ConfigFile) (bool, []string, string, string) { |
| 218 | vals := mysqlValues(fs, "secure_file_priv") |
| 219 | ev := mysqlEvidence(fs, "secure_file_priv") |
| 220 | for _, v := range vals { |
| 221 | if strings.TrimSpace(v) != "" { |
| 222 | return false, ev, "", "" |
| 223 | } |
| 224 | } |
| 225 | if len(vals) > 0 { |
| 226 | return true, ev, "", "" // explicitly set to an empty value |
| 227 | } |
| 228 | fl := detectMySQLFlavor(ctx, s) |
| 229 | if fl.modernMySQL() { |
| 230 | return false, []string{"secure_file_priv is not set in the option files", "server is " + fl.String()}, "secure_file_priv is not set, but packaged MySQL 8.0 and later builds default it to a dedicated folder (usually /var/lib/mysql-files)", "" |
| 231 | } |
| 232 | if !fl.known() { |
| 233 | return false, []string{"secure_file_priv is not set in the option files"}, "", "secure_file_priv is not set, and the server flavour and version could not be read: MariaDB defaults it to empty (unrestricted), packaged MySQL 8.0 and later restrict it" |
| 234 | } |
| 235 | return true, []string{"secure_file_priv is not set in the option files; " + fl.String() + " leaves it empty by default"}, "", "" |
| 236 | }, |
| 237 | }) |
| 238 | |
| 239 | registerDepth(depthRule{ |
| 240 | id: "NET-DB-016", title: "MySQL Does Not Require Encrypted Client Connections", sev: check.Medium, cat: check.CatCrypto, |
| 241 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 242 | summary: "require_secure_transport is not ON, so clients may log in and query over plaintext TCP.", |
| 243 | rationale: "Without the requirement, a client that is misconfigured or downgraded sends credentials and data in the clear across the network. The setting is harmless for Unix socket connections and makes TLS a server-side rule rather than a client choice. When bind-address keeps the server on loopback (127.0.0.1, ::1 or localhost) the plaintext path never leaves the machine and only a local root user could read it, so the check reports Pass with a note instead of a Medium finding; the Medium applies to servers reachable from the network.", |
| 244 | detection: "Parses the option files for require_secure_transport under [mysqld]. Fails unless it is ON. Passes with a note when skip-networking is on (no TCP) or every bind-address value is a loopback address, since no network path exists to protect.", |
| 245 | fix: "Provision a server certificate (MySQL creates one by default), then add require_secure_transport = ON under [mysqld] and restart in a maintenance window. Test every application connection string first.", |
| 246 | verify: "SHOW VARIABLES LIKE 'require_secure_transport'; should print ON, and a client that disables TLS (ssl-mode DISABLED) connecting to 127.0.0.1 should be refused.", |
| 247 | patterns: mysqlFiles, markers: mysqlMarkers, |
| 248 | refs: []check.Ref{{Title: "MySQL 8.4 Reference Manual, Configuring MySQL to Use Encrypted Connections", URL: "https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html"}, refNISTSC8}, |
| 249 | std: check.Standards{CWE: []string{"CWE-319"}, OWASP: []string{"A02:2021"}, ASVS: []string{"9.2.2"}, NIST: []string{"SC-8", "SC-13"}, CISTopic: "Database transport encryption"}, |
| 250 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 251 | if skipNetworking(fs) { |
| 252 | return false, []string{"skip-networking is on; no TCP connections are accepted"} |
| 253 | } |
| 254 | var binds []string |
| 255 | for _, v := range mysqlValues(fs, "bind-address") { |
| 256 | binds = append(binds, strings.Split(v, ",")...) // MySQL 8 accepts a comma-separated list |
| 257 | } |
| 258 | if len(binds) > 0 && allLoopback(binds) { |
| 259 | return false, append([]string{"bind-address is loopback only, so plaintext connections never leave this machine; TLS is still recommended before exposing the port"}, mysqlEvidence(fs, "bind_address", "require_secure_transport")...) |
| 260 | } |
| 261 | ok := false |
| 262 | for _, v := range mysqlValues(fs, "require_secure_transport") { |
| 263 | if isOn(v) { |
| 264 | ok = true |
| 265 | } |
| 266 | } |
| 267 | ev := mysqlEvidence(fs, "require_secure_transport") |
| 268 | if len(ev) == 0 { |
| 269 | ev = []string{"require_secure_transport is not set; the default is OFF"} |
| 270 | } |
| 271 | return !ok, ev |
| 272 | }, |
| 273 | }) |
| 274 | |
| 275 | registerDepth(depthRule{ |
| 276 | id: "NET-DB-017", title: "MySQL Error Log Not Configured", sev: check.Low, cat: check.CatLogging, |
| 277 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", |
| 278 | summary: "log_error is not set, so server errors, failed logins and startup warnings go to the console or syslog without a dedicated, retained file.", |
| 279 | rationale: "The error log is the first place to see authentication failures, crashes and plugin warnings. A named file with rotation makes those events available to incident response and to the audit checks that read it.", |
| 280 | detection: "Parses the option files for log_error (or log-error) under [mysqld]. Fails when absent.", |
| 281 | fix: "Add log_error = /var/log/mysql/error.log under [mysqld], make the folder writable by the mysql user, and add a logrotate rule.", |
| 282 | verify: "SHOW VARIABLES LIKE 'log_error'; should print the file path.", |
| 283 | patterns: mysqlFiles, markers: mysqlMarkers, |
| 284 | refs: []check.Ref{{Title: "MySQL 8.4 Reference Manual, The Error Log", URL: "https://dev.mysql.com/doc/refman/8.4/en/error-log.html"}, refMySQLOpts}, |
| 285 | std: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, ASVS: []string{"7.1.1"}, NIST: []string{"AU-2", "AU-12"}, CISTopic: "Database error logging"}, |
| 286 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 287 | ev := mysqlEvidence(fs, "log_error") |
| 288 | if !mysqlSet(fs, "log_error") { |
| 289 | return true, []string{"log_error is not set in the option files"} |
| 290 | } |
| 291 | return false, ev |
| 292 | }, |
| 293 | }) |
| 294 | |
| 295 | registerDepth(depthRule{ |
| 296 | id: "NET-DB-018", title: "MySQL Resolves Client Host Names", sev: check.Low, cat: check.CatData, |
| 297 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", |
| 298 | summary: "skip-name-resolve is not set, so grants that name hosts depend on DNS answers and every connection waits on a reverse lookup.", |
| 299 | rationale: "Host-based grants that trust names can be satisfied by whoever controls DNS or the resolver path. With skip-name-resolve the server matches IP addresses only, which is both faster and not spoofable through DNS. Grants that use host names must be rewritten first.", |
| 300 | detection: "Parses the option files for skip-name-resolve, skip_name_resolve or host_cache_size=0 under [mysqld]. Fails when none is present.", |
| 301 | fix: "Rewrite grants that use host names to IP addresses or '%', then add skip-name-resolve under [mysqld] and restart in a maintenance window.", |
| 302 | verify: "SHOW VARIABLES LIKE 'skip_name_resolve'; should print ON.", |
| 303 | patterns: mysqlFiles, markers: mysqlMarkers, |
| 304 | refs: []check.Ref{{Title: "MySQL 8.4 Reference Manual, skip_name_resolve", URL: "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_skip_name_resolve"}, refMySQLSec}, |
| 305 | std: dbStd([]string{"CWE-350", "CWE-290"}, "CM-6", "IA-3"), |
| 306 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 307 | ok := anyMySQL(fs, "skip-name-resolve", func(v string) bool { return strings.TrimSpace(v) == "" || isOn(v) }) |
| 308 | if anyMySQL(fs, "skip-name-resolve", isOff) { |
| 309 | ok = false |
| 310 | } |
| 311 | ev := mysqlEvidence(fs, "skip_name_resolve") |
| 312 | if len(ev) == 0 { |
| 313 | ev = []string{"skip-name-resolve is not set; the default is OFF"} |
| 314 | } |
| 315 | return !ok, ev |
| 316 | }, |
| 317 | }) |
| 318 | |
| 319 | // PostgreSQL |
| 320 | registerDepth(depthRule{ |
| 321 | id: "NET-DB-019", title: "PostgreSQL Uses md5 or password Authentication", sev: check.Medium, cat: check.CatIdentity, |
| 322 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 323 | summary: "pg_hba.conf has active rules with the md5 or password method instead of scram-sha-256.", |
| 324 | rationale: "The password method sends the password in clear text unless the connection is encrypted; md5 is a fast unsalted construction whose stored hashes work as passwords themselves (pass-the-hash) and which the PostgreSQL documentation describes as obsolete. SCRAM-SHA-256 fixes both.", |
| 325 | detection: "Reads pg_hba.conf, ignores comments, and flags every rule whose method is md5 or password. NET-DB-004 already covers trust and open address ranges.", |
| 326 | fix: "Set password_encryption = scram-sha-256 in postgresql.conf, have every role reset its password so a SCRAM verifier is stored, then change the pg_hba.conf methods to scram-sha-256 and reload.", |
| 327 | verify: "SELECT * FROM pg_hba_file_rules WHERE auth_method IN ('md5','password'); should return no rows.", |
| 328 | patterns: pgHBA, markers: pgMarkers, |
| 329 | refs: []check.Ref{refPGAuth, {Title: "PostgreSQL, Password Authentication", URL: "https://www.postgresql.org/docs/current/auth-password.html"}, refNISTDigest}, |
| 330 | std: check.Standards{CWE: []string{"CWE-328", "CWE-916"}, OWASP: []string{"A02:2021", "A07:2021"}, ASVS: []string{"2.4.1"}, NIST: []string{"IA-5", "IA-5(1)", "SC-13"}, CISTopic: "Database password hashing and authentication method"}, |
| 331 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 332 | var ev []string |
| 333 | // ReadConfigs keys by the first word ("host"), so rules are |
| 334 | // rebuilt from the key/value pairs: key = type, value = rest. |
| 335 | ev = nil |
| 336 | for _, f := range fs { |
| 337 | for _, typ := range []string{"host", "hostssl", "hostnossl", "hostgssenc", "hostnogssenc", "local"} { |
| 338 | for _, rest := range f.Values[typ] { |
| 339 | fields := strings.Fields(rest) |
| 340 | if len(fields) == 0 { |
| 341 | continue |
| 342 | } |
| 343 | method := strings.ToLower(fields[len(fields)-1]) |
| 344 | // Method may be followed by options such as clientcert=1. |
| 345 | for i := len(fields) - 1; i >= 0; i-- { |
| 346 | if !strings.Contains(fields[i], "=") { |
| 347 | method = strings.ToLower(fields[i]) |
| 348 | break |
| 349 | } |
| 350 | } |
| 351 | if method == "md5" || method == "password" { |
| 352 | ev = append(ev, f.Path+": "+typ+" "+rest) |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | return len(ev) > 0, ev |
| 358 | }, |
| 359 | passMsg: "No pg_hba.conf rule uses md5 or password", |
| 360 | }) |
| 361 | |
| 362 | registerDepth(depthRule{ |
| 363 | id: "NET-DB-020", title: "PostgreSQL SSL Not Enabled", sev: check.Medium, cat: check.CatCrypto, |
| 364 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 365 | summary: "postgresql.conf does not set ssl = on, so TCP clients cannot encrypt their sessions and hostssl rules cannot apply.", |
| 366 | rationale: "Without SSL every query and result crosses the network in the clear. Enabling it costs one certificate and lets pg_hba.conf require hostssl for remote clients. When listen_addresses keeps the server on loopback (the PostgreSQL default, localhost) the plaintext path never leaves the machine and only a local root user could read it, so the check reports Pass with a note instead of a Medium finding; the Medium applies to servers reachable from the network.", |
| 367 | detection: "Reads postgresql.conf and flags ssl absent or off. Passes with a note when listen_addresses is empty (no TCP at all) or names only loopback addresses (localhost, 127.0.0.1, ::1), or when it is unset, since the PostgreSQL default is localhost.", |
| 368 | fix: "Install a certificate and key readable only by the postgres user, set ssl = on , ssl_cert_file and ssl_key_file, reload, then change remote pg_hba.conf rules from host to hostssl.", |
| 369 | verify: "SHOW ssl; should print on, and psql \"sslmode=require host=127.0.0.1\" should connect.", |
| 370 | patterns: pgConf, markers: pgMarkers, |
| 371 | refs: []check.Ref{refPGSSL, refNISTSC8}, |
| 372 | std: check.Standards{CWE: []string{"CWE-319"}, OWASP: []string{"A02:2021"}, ASVS: []string{"9.2.2"}, NIST: []string{"SC-8", "SC-13"}, CISTopic: "Database transport encryption"}, |
| 373 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 374 | la, ok := pgValue(fs, "listen_addresses") |
| 375 | if ok && la == "" { |
| 376 | return false, []string{"listen_addresses is empty; no TCP connections are accepted"} |
| 377 | } |
| 378 | if !ok { |
| 379 | return false, []string{"listen_addresses is not set; the PostgreSQL default is localhost, so plaintext connections never leave this machine; enable ssl before exposing the port"} |
| 380 | } |
| 381 | if allLoopback(strings.Split(la, ",")) { |
| 382 | return false, []string{"listen_addresses = " + la + " is loopback only, so plaintext connections never leave this machine; enable ssl before exposing the port"} |
| 383 | } |
| 384 | v, ok := pgValue(fs, "ssl") |
| 385 | if !ok { |
| 386 | return true, []string{"ssl is not set; the default is off"} |
| 387 | } |
| 388 | return !isOn(v), evidence(fs, "ssl") |
| 389 | }, |
| 390 | }) |
| 391 | |
| 392 | registerDepth(depthRule{ |
| 393 | id: "NET-DB-021", title: "PostgreSQL Connection Logging Off", sev: check.Low, cat: check.CatLogging, |
| 394 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", |
| 395 | summary: "log_connections and log_disconnections are off, so successful logins leave no record.", |
| 396 | rationale: "Failed logins are logged by default, but successful ones are not. Knowing which role connected from where and for how long is the base evidence for spotting credential misuse and for any later investigation.", |
| 397 | detection: "Reads postgresql.conf and flags log_connections not on. log_disconnections is reported as evidence.", |
| 398 | fix: "Set log_connections = on and log_disconnections = on in postgresql.conf and reload. On PostgreSQL 18 and newer, log_connections accepts a list such as receipt,authentication,authorization .", |
| 399 | verify: "SHOW log_connections; should print on (or a non-empty list).", |
| 400 | patterns: pgConf, markers: pgMarkers, |
| 401 | refs: []check.Ref{refPGLog}, |
| 402 | std: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, ASVS: []string{"7.1.3"}, NIST: []string{"AU-2", "AU-12", "AC-2(4)"}, CISTopic: "Database connection logging"}, |
| 403 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 404 | v, ok := pgValue(fs, "log_connections") |
| 405 | ev := evidence(fs, "log_connections", "log_disconnections") |
| 406 | if !ok { |
| 407 | return true, []string{"log_connections is not set; the default is off"} |
| 408 | } |
| 409 | bad := isOff(v) || v == "" |
| 410 | return bad, ev |
| 411 | }, |
| 412 | }) |
| 413 | |
| 414 | registerDepth(depthRule{ |
| 415 | id: "NET-DB-022", title: "PostgreSQL Password Encryption Not SCRAM", sev: check.Medium, cat: check.CatIdentity, |
| 416 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 417 | summary: "password_encryption is set to md5, so new and changed passwords are stored as md5 verifiers that work as pass-the-hash credentials.", |
| 418 | rationale: "The stored md5 value is itself sufficient to authenticate, so a leaked catalog is a leaked set of passwords. SCRAM-SHA-256 stores a salted, iterated verifier that is not usable directly. PostgreSQL 14 and newer default to scram-sha-256; older clusters often carry an explicit md5 line.", |
| 419 | detection: "Reads postgresql.conf and flags password_encryption = md5 (or on, the legacy alias). Absent passes on the assumption of the modern default and is noted as evidence.", |
| 420 | fix: "Set password_encryption = scram-sha-256 , reload, and ask every role to reset its password so the verifier is regenerated. Then move pg_hba.conf to scram-sha-256 (NET-DB-019).", |
| 421 | verify: "SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%'; should return no rows after the resets.", |
| 422 | patterns: pgConf, markers: pgMarkers, |
| 423 | refs: []check.Ref{{Title: "PostgreSQL, Password Authentication", URL: "https://www.postgresql.org/docs/current/auth-password.html"}, refPGConn, refNISTDigest}, |
| 424 | std: check.Standards{CWE: []string{"CWE-916", "CWE-328"}, OWASP: []string{"A02:2021"}, ASVS: []string{"2.4.1"}, NIST: []string{"IA-5(1)", "SC-13"}, CISTopic: "Database password hashing"}, |
| 425 | evaluate: func(fs []collect.ConfigFile) (bool, []string) { |
| 426 | v, ok := pgValue(fs, "password_encryption") |
| 427 | if !ok { |
| 428 | return false, []string{"password_encryption is not set; PostgreSQL 14 and newer default to scram-sha-256"} |
| 429 | } |
| 430 | lv := strings.ToLower(v) |
| 431 | return lv == "md5" || lv == "on" || lv == "true", evidence(fs, "password_encryption") |
| 432 | }, |
| 433 | }) |
| 434 | |
| 435 | // Redis |
| 436 | registerDepth(depthRule{ |
| 437 | id: "NET-DB-023", title: "Redis Dangerous Commands Not Restricted", sev: check.Medium, cat: check.CatData, |
| 438 | cvss: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L", |
| 439 | summary: "redis.conf neither renames nor disables CONFIG, FLUSHALL, FLUSHDB, DEBUG and KEYS, and no ACL user excludes the @dangerous category.", |
| 440 | rationale: "An attacker who reaches Redis with any credential can wipe data with FLUSHALL, rewrite the configuration with CONFIG SET to write files (the classic path to a cron job or SSH key), or stall the server with KEYS *. Removing or renaming these for application users limits what a stolen credential can do.", |
| 441 | detection: "Parses redis.conf for rename-command lines covering CONFIG, FLUSHALL, FLUSHDB, DEBUG and KEYS, or an ACL definition (user lines, or an aclfile) that excludes @dangerous or @admin. Fails when neither protection exists, unless every bind address is loopback and protected-mode is not off: then only local processes can reach Redis and the result is Pass with a note (Low exposure).", |
| 442 | fix: "Prefer ACLs on Redis 6 and newer: user app on >secret ~app:* -@all +@read +@write -@dangerous . Otherwise add rename-command FLUSHALL \"\" (and the same for FLUSHDB, CONFIG, DEBUG, KEYS) and restart.", |
| 443 | verify: "redis-cli -a <secret> ACL LIST or redis-cli CONFIG GET dir should be refused for the application user.", |
| 444 | patterns: redisFiles, markers: redisMarkers, |
| 445 | refs: []check.Ref{refRedisSec, refRedisACL}, |
| 446 | std: check.Standards{CWE: []string{"CWE-749"}, OWASP: []string{"A01:2021", "A05:2021"}, ASVS: []string{"14.1.3"}, NIST: []string{"AC-6", "CM-7"}, CISTopic: "Data store administrative command restriction"}, |
| 447 | evaluateSys: func(_ context.Context, _ sys.System, fs []collect.ConfigFile) (bool, []string, string) { |
| 448 | renamed := map[string]bool{} |
| 449 | for _, v := range values(fs, "rename-command") { |
| 450 | f := strings.Fields(strings.ToLower(v)) |
| 451 | if len(f) >= 1 { |
| 452 | renamed[f[0]] = true |
| 453 | } |
| 454 | } |
| 455 | allRenamed := renamed["config"] && renamed["flushall"] && renamed["flushdb"] |
| 456 | aclRestricts := false |
| 457 | for _, v := range values(fs, "user") { |
| 458 | lv := strings.ToLower(v) |
| 459 | if strings.Contains(lv, "-@dangerous") || strings.Contains(lv, "-@admin") || (strings.Contains(lv, "-@all") && !strings.Contains(lv, "+@all")) { |
| 460 | aclRestricts = true |
| 461 | } |
| 462 | } |
| 463 | if len(values(fs, "aclfile")) > 0 { |
| 464 | aclRestricts = true // the ACL file is owned by the administrator; its content is not read here |
| 465 | } |
| 466 | ev := evidence(fs, "rename-command", "user", "aclfile") |
| 467 | if len(ev) == 0 { |
| 468 | ev = []string{"no rename-command, user or aclfile directive"} |
| 469 | } |
| 470 | if allRenamed || aclRestricts { |
| 471 | return false, ev, "" |
| 472 | } |
| 473 | if redisLoopbackOnly(fs) { |
| 474 | return false, append(ev, evidence(fs, "bind", "protected-mode")...), "Dangerous commands are not restricted, but Redis binds to loopback only with protected-mode on, so only local processes can reach them (Low exposure); restrict them before exposing the port" |
| 475 | } |
| 476 | return true, ev, "" |
| 477 | }, |
| 478 | }) |
| 479 | } |
| 480 | |
| 481 | // redisLoopbackOnly reports whether redis.conf binds only to loopback |
| 482 | // addresses (a leading "-" marks an optional address in Redis 7) and keeps |
| 483 | // protected-mode on. A missing bind line listens on every interface. |
| 484 | func redisLoopbackOnly(fs []collect.ConfigFile) bool { |
| 485 | binds := values(fs, "bind") |
| 486 | if len(binds) == 0 { |
| 487 | return false |
| 488 | } |
| 489 | last := strings.Fields(binds[len(binds)-1]) |
| 490 | for i, a := range last { |
| 491 | last[i] = strings.TrimPrefix(a, "-") |
| 492 | } |
| 493 | if !allLoopback(last) { |
| 494 | return false |
| 495 | } |
| 496 | pm := values(fs, "protected-mode") |
| 497 | return len(pm) == 0 || strings.ToLower(strings.TrimSpace(pm[len(pm)-1])) != "no" |
| 498 | } |
| 499 | |
| 500 | // skipNetworking reports whether the option files turn TCP off: a bare |
| 501 | // skip-networking line, or skip-networking set to an on value. |
| 502 | func skipNetworking(fs []collect.ConfigFile) bool { |
| 503 | for _, v := range mysqlValues(fs, "skip-networking") { |
| 504 | if strings.TrimSpace(v) == "" || isOn(v) { |
| 505 | return true |
| 506 | } |
| 507 | } |
| 508 | return false |
| 509 | } |
| 510 | |
| 511 | // allLoopback reports whether every address in the list is a loopback |
| 512 | // address (127.0.0.1, any 127.x, ::1 or localhost). An empty list is not |
| 513 | // loopback: it means the server default, which differs per service. |
| 514 | func allLoopback(addrs []string) bool { |
| 515 | if len(addrs) == 0 { |
| 516 | return false |
| 517 | } |
| 518 | for _, a := range addrs { |
| 519 | a = strings.ToLower(strings.Trim(strings.TrimSpace(a), `'"`)) |
| 520 | if a == "localhost" || a == "::1" || strings.HasPrefix(a, "127.") || a == "::ffff:127.0.0.1" { |
| 521 | continue |
| 522 | } |
| 523 | return false |
| 524 | } |
| 525 | return true |
| 526 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). NET-DNS-002 BIND DNSSEC Validation Disabled (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/NET-DNS-002.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_net_dns_002,
author = {{OpenVAPT}},
title = {{NET-DNS-002 BIND DNSSEC Validation Disabled}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/NET-DNS-002.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.