Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
LowNET-DB-018Data ServicesNetwork

MySQL Resolves Client Host Names

skip-name-resolve is not set, so grants that name hosts depend on DNS answers and every connection waits on a reverse lookup.

Why It Matters
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.
How It Detects
Parses the option files for skip-name-resolve, skip_name_resolve or host_cache_size=0 under [mysqld]. Fails when none is present.
How to Fix
Rewrite grants that use host names to IP addresses or '%', then add skip-name-resolve under [mysqld] and restart in a maintenance window.
How to Verify
SHOW VARIABLES LIKE 'skip_name_resolve'; should print ON.
CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/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-350, CWE-290
OWASP Top 10
A05:2021
OWASP ASVS 4.0.3
14.1.3
NIST SP 800-53 Rev. 5
CM-6, IA-3
Benchmark Topic
Database server hardening (topic only, no benchmark text)
References
MySQL 8.4 Reference Manual, skip_name_resolve
MySQL 8.4 Reference Manual, Security Guidelines
Learn the theory behind this check: P3 Network Exposure and Firewalls, P8 Data Services: Databases, File Shares, DNS and Mail, P19 Database, Mail and DNS Hardening.

Source Code

The complete file that defines this check. Run it with openvapt scan --only NET-DB-018, or read it offline with openvapt explain NET-DB-018.

internal/checks/network/data_depth.go · Lines 1-526View on GitHub at This Commit
1package network
2
3import (
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.
22type 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
43func 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
52func 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
97var (
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.
120func 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
131func mysqlSet(fs []collect.ConfigFile, key string) bool { return len(mysqlValues(fs, key)) > 0 }
132
133func 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
142func 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.
155func 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
163var isOn = func(v string) bool { v = strings.ToLower(v); return v == "on" || v == "1" || v == "true" || v == "yes" }
164var isOff = func(v string) bool {
165 v = strings.ToLower(v)
166 return v == "off" || v == "0" || v == "false" || v == "no"
167}
168
169func 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.
484func 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.
502func 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.
514func 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.

APA 7
OpenVAPT. (2026). NET-DB-018 MySQL Resolves Client Host Names (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/NET-DB-018.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.

BibTeX
@misc{openvapt_net_db_018,
  author       = {{OpenVAPT}},
  title        = {{NET-DB-018 MySQL Resolves Client Host Names}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/NET-DB-018.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.