No security.txt Contact File
Checks whether each site publishes /.well-known/security.txt (RFC 9116).
- Why It Matters
- security.txt tells researchers how to report a vulnerability. Without it, reports may go unheard or be disclosed publicly instead.
- How It Detects
- Requests /.well-known/security.txt and looks for a Contact: field. This is informational and does not affect the risk summary.
- How to Fix
- Publish /.well-known/security.txt with at least Contact: and Expires: fields (see securitytxt.org).
- How to Verify
curl -s https://<site>/.well-known/security.txt- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N- Control Category
- Web Application Security: CIS Controls v8 16; NIST CSF 2.0 PR.PS, PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 16.2
- NIST CSF 2.0 Subcategory
- ID.RA-08
- CWE
- CWE-1188
- NIST SP 800-53 Rev. 5
- RA-5(11)
- References
- RFC 9116, A File Format to Aid in Security Vulnerability Disclosure
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-SEC-001, or read it offline with openvapt explain WEB-SEC-001.
internal/checks/web/docroot.go · Lines 1-259View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/openvapt/openvapt/internal/check" |
| 10 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 11 | "github.com/openvapt/openvapt/internal/sys" |
| 12 | ) |
| 13 | |
| 14 | // Sensitive file name patterns that should never sit inside a web root. |
| 15 | var sensitiveNames = []struct{ pattern, why string }{ |
| 16 | {".env", "environment secrets"}, {".env.*", "environment secrets"}, {"*.sql", "database dump"}, {"*.sql.gz", "database dump"}, |
| 17 | {"*.bak", "backup copy"}, {"*.old", "backup copy"}, {"*.orig", "backup copy"}, {"*~", "editor backup"}, |
| 18 | {"*.swp", "editor swap file"}, {"backup*.zip", "backup archive"}, {"backup*.tar.gz", "backup archive"}, {"backup*.tgz", "backup archive"}, |
| 19 | {"*dump*.tar.gz", "database dump archive"}, {"*dump*.tgz", "database dump archive"}, {"*dump*.zip", "database dump archive"}, |
| 20 | {"id_rsa", "SSH private key"}, {"id_ed25519", "SSH private key"}, {"id_ecdsa", "SSH private key"}, |
| 21 | {"*key*.pem", "private key"}, {"*priv*.pem", "private key"}, {"*.key", "private key"}, {"*.p12", "key store"}, {"*.pfx", "key store"}, {"wp-config.php.*", "WordPress config copy"}, |
| 22 | } |
| 23 | |
| 24 | // reviewNames are files that are often legitimate inside a web root (a |
| 25 | // published certificate chain, a downloadable release archive) but deserve a |
| 26 | // look, because the same names also carry private keys and site backups. |
| 27 | // They are rated Medium by WEB-DOC-003, not High by WEB-DOC-001 (C-09). |
| 28 | var reviewNames = []struct{ pattern, why string }{ |
| 29 | {"*.pem", "certificate or key material; confirm it holds no private key"}, {"*.crt", "certificate; confirm the key is not beside it"}, |
| 30 | {"*.tar.gz", "archive; confirm it is a published download, not a site backup"}, {"*.tgz", "archive; confirm it is a published download, not a site backup"}, |
| 31 | {"*.tar", "archive; confirm it is a published download, not a site backup"}, {"*.7z", "archive; confirm it is a published download, not a site backup"}, |
| 32 | } |
| 33 | |
| 34 | // downloadFolder reports a path under a folder whose purpose is publishing |
| 35 | // files, where archives are expected. |
| 36 | func downloadFolder(path string) bool { |
| 37 | l := strings.ToLower(path) |
| 38 | for _, d := range []string{"/download/", "/downloads/", "/releases/", "/dist/", "/.well-known/"} { |
| 39 | if strings.Contains(l, d) { |
| 40 | return true |
| 41 | } |
| 42 | } |
| 43 | return false |
| 44 | } |
| 45 | |
| 46 | func review(name string) (string, bool) { |
| 47 | if _, hi := sensitive(name); hi { |
| 48 | return "", false |
| 49 | } |
| 50 | for _, s := range reviewNames { |
| 51 | if ok, _ := filepath.Match(s.pattern, name); ok { |
| 52 | return s.why, true |
| 53 | } |
| 54 | } |
| 55 | return "", false |
| 56 | } |
| 57 | |
| 58 | func sensitive(name string) (string, bool) { |
| 59 | for _, s := range sensitiveNames { |
| 60 | if ok, _ := filepath.Match(s.pattern, name); ok { |
| 61 | return s.why, true |
| 62 | } |
| 63 | } |
| 64 | return "", false |
| 65 | } |
| 66 | |
| 67 | func init() { |
| 68 | check.Register(&check.Check{ |
| 69 | ID: "WEB-DOC-001", Title: "Secrets or Backups Stored Inside a Web Root", Domain: check.DomainWeb, |
| 70 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortLow, |
| 71 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 72 | Summary: "Looks on disk inside each web document root for environment files, database dumps, backups, archives and private keys.", |
| 73 | Rationale: "Anything inside a document root may be downloadable, now or after a configuration change. Dumps, backups and keys there are a leading cause of data breaches; finding them on disk catches files the URL checks cannot guess.", |
| 74 | Detection: "Reads document roots from the local Apache, nginx and LiteSpeed configuration, walks each one (metadata only, up to 50000 files, symlinks not followed) and matches file names such as .env, *.sql, *.bak, backup*.tar.gz, id_rsa, *key*.pem and *.key. File contents are never read. Plain archives and certificates (*.tar.gz, *.pem) are reviewed separately at Medium by WEB-DOC-003, because they are often published on purpose.", |
| 75 | Fix: "Move each listed file outside the document root (for example to a 700 folder in the account's home), delete what is not needed, and rotate any secret or key that was exposed. Keep backups in a dedicated location outside every web root.", |
| 76 | Verify: "Re-run this check; no files should be listed.", |
| 77 | Refs: []check.Ref{refWSTGConf04, {Title: "OWASP Secrets Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html"}}, |
| 78 | Standards: check.Standards{CWE: []string{"CWE-530", "CWE-538", "CWE-312"}, OWASP: []string{"A05:2021"}, ASVS: []string{"12.5.1"}, WSTG: []string{"WSTG-CONF-04"}, NIST: []string{"SC-28", "CM-6"}}, |
| 79 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 80 | roots := collect.DocRoots(ctx, s) |
| 81 | if len(roots) == 0 { |
| 82 | return []check.Result{check.SkipR("web roots", "No document roots found in the local web server configuration")} |
| 83 | } |
| 84 | var out []check.Result |
| 85 | for _, root := range roots { |
| 86 | w, err := s.Walk(ctx, root, 50000) |
| 87 | if err != nil && len(w.Files) == 0 { |
| 88 | continue |
| 89 | } |
| 90 | var hits []string |
| 91 | for _, f := range w.Files { |
| 92 | if why, ok := sensitive(filepath.Base(f.Path)); ok { |
| 93 | hits = append(hits, f.Path+" ("+why+")") |
| 94 | } |
| 95 | } |
| 96 | switch { |
| 97 | case len(hits) > 0: |
| 98 | if len(hits) > 30 { |
| 99 | hits = append(hits[:30], fmt.Sprintf("... and %d more", len(hits)-30)) |
| 100 | } |
| 101 | out = append(out, check.FailR(root, fmt.Sprintf("%d sensitive file(s) inside the web root", len(hits)), hits...)) |
| 102 | case !w.Complete: |
| 103 | out = append(out, check.IncompleteR(root, "Web root could not be fully walked")) |
| 104 | default: |
| 105 | out = append(out, check.PassR(root, fmt.Sprintf("No sensitive files among %d checked", len(w.Files)))) |
| 106 | } |
| 107 | } |
| 108 | if len(out) == 0 { |
| 109 | return []check.Result{check.SkipR("web roots", "Document roots could not be read")} |
| 110 | } |
| 111 | return out |
| 112 | }, |
| 113 | }) |
| 114 | |
| 115 | check.Register(&check.Check{ |
| 116 | ID: "WEB-DOC-003", Title: "Archives or Certificates Inside a Web Root", Domain: check.DomainWeb, |
| 117 | Severity: check.Medium, Category: check.CatExposure, Effort: check.EffortLow, |
| 118 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", |
| 119 | Summary: "Lists archives (*.tar.gz, *.tgz, *.tar, *.7z) and certificate files (*.pem, *.crt) inside each web document root for review, outside download folders.", |
| 120 | Rationale: "A tarball inside a web root is often a site or database backup left after a migration, and a .pem file sometimes carries the private key next to the certificate. Both are downloadable by anyone who guesses the name. But download sites publish archives on purpose and public certificates are harmless, so this is a Medium review item, not a High finding; names that clearly mean backup, dump or key are rated High by WEB-DOC-001.", |
| 121 | Detection: "Walks each document root (metadata only, up to 50000 files, symlinks not followed) and lists files matching the archive and certificate patterns that WEB-DOC-001 does not already rate High. Files under /download/, /downloads/, /releases/, /dist/ or /.well-known/ are noted as expected publications and do not fail. File contents are never read.", |
| 122 | Fix: "Confirm each file is meant to be public. Move backups and anything holding a private key outside the document root, rotate any exposed key, and keep published downloads in a dedicated folder such as /downloads/.", |
| 123 | Verify: "Re-run this check; only files you have confirmed as intended downloads should remain, in a download folder.", |
| 124 | Refs: []check.Ref{refWSTGConf04, {Title: "OWASP Secrets Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html"}}, |
| 125 | Standards: check.Standards{CWE: []string{"CWE-538", "CWE-552"}, OWASP: []string{"A05:2021"}, ASVS: []string{"12.5.1"}, WSTG: []string{"WSTG-CONF-04"}, NIST: []string{"SC-28", "CM-6"}}, |
| 126 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 127 | roots := collect.DocRoots(ctx, s) |
| 128 | if len(roots) == 0 { |
| 129 | return []check.Result{check.SkipR("web roots", "No document roots found in the local web server configuration")} |
| 130 | } |
| 131 | var out []check.Result |
| 132 | for _, root := range roots { |
| 133 | w, err := s.Walk(ctx, root, 50000) |
| 134 | if err != nil && len(w.Files) == 0 { |
| 135 | continue |
| 136 | } |
| 137 | var hits, expected []string |
| 138 | for _, f := range w.Files { |
| 139 | why, ok := review(filepath.Base(f.Path)) |
| 140 | if !ok { |
| 141 | continue |
| 142 | } |
| 143 | if downloadFolder(f.Path) { |
| 144 | expected = append(expected, f.Path+" (in a download folder, expected)") |
| 145 | continue |
| 146 | } |
| 147 | hits = append(hits, f.Path+" ("+why+")") |
| 148 | } |
| 149 | switch { |
| 150 | case len(hits) > 0: |
| 151 | if len(hits) > 30 { |
| 152 | hits = append(hits[:30], fmt.Sprintf("... and %d more", len(hits)-30)) |
| 153 | } |
| 154 | out = append(out, check.FailR(root, fmt.Sprintf("%d archive or certificate file(s) inside the web root to review", len(hits)), append(hits, expected...)...)) |
| 155 | case !w.Complete: |
| 156 | out = append(out, check.IncompleteR(root, "Web root could not be fully walked")) |
| 157 | default: |
| 158 | out = append(out, check.PassR(root, fmt.Sprintf("No archives or certificates to review among %d files", len(w.Files)), expected...)) |
| 159 | } |
| 160 | } |
| 161 | if len(out) == 0 { |
| 162 | return []check.Result{check.SkipR("web roots", "Document roots could not be read")} |
| 163 | } |
| 164 | return out |
| 165 | }, |
| 166 | }) |
| 167 | |
| 168 | check.Register(&check.Check{ |
| 169 | ID: "WEB-DOC-002", Title: "Web Root Files Writable by Other Users", Domain: check.DomainWeb, |
| 170 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 171 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H", |
| 172 | Summary: "Looks for world-writable files inside web document roots.", |
| 173 | Rationale: "A world-writable PHP or HTML file lets any local user, or any other compromised site on the same server, plant a web shell or inject malware into pages served to visitors.", |
| 174 | Detection: "Walks each document root (metadata only, up to 50000 files, symlinks not followed) and lists regular files with the 'other write' permission bit.", |
| 175 | Fix: "chmod o-w <file> for each listed file (find <docroot> -type f -perm -0002 -exec chmod o-w {} +). Use 644 for files and 755 for folders, owned by the site account, and grant the web server write access only to upload folders.", |
| 176 | Verify: "find <docroot> -type f -perm -0002 should print nothing.", |
| 177 | Refs: []check.Ref{{Title: "OWASP WSTG-CONF-09, Test File Permission", URL: "https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/09-Test_File_Permission/"}}, |
| 178 | Standards: check.Standards{CWE: []string{"CWE-732"}, OWASP: []string{"A05:2021"}, WSTG: []string{"WSTG-CONF-09"}, NIST: []string{"AC-3", "AC-6"}}, |
| 179 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 180 | roots := collect.DocRoots(ctx, s) |
| 181 | if len(roots) == 0 { |
| 182 | return []check.Result{check.SkipR("web roots", "No document roots found in the local web server configuration")} |
| 183 | } |
| 184 | var out []check.Result |
| 185 | for _, root := range roots { |
| 186 | w, err := s.Walk(ctx, root, 50000) |
| 187 | if err != nil && len(w.Files) == 0 { |
| 188 | continue |
| 189 | } |
| 190 | var bad []string |
| 191 | for _, f := range w.Files { |
| 192 | if f.Mode.Perm()&0o002 != 0 { |
| 193 | bad = append(bad, fmt.Sprintf("%04o %s", f.Mode.Perm(), f.Path)) |
| 194 | } |
| 195 | } |
| 196 | switch { |
| 197 | case len(bad) > 0: |
| 198 | if len(bad) > 30 { |
| 199 | bad = append(bad[:30], fmt.Sprintf("... and %d more", len(bad)-30)) |
| 200 | } |
| 201 | out = append(out, check.FailR(root, fmt.Sprintf("%d world-writable file(s) in the web root", len(bad)), bad...)) |
| 202 | case !w.Complete: |
| 203 | out = append(out, check.IncompleteR(root, "Web root could not be fully walked")) |
| 204 | default: |
| 205 | out = append(out, check.PassR(root, "No world-writable files")) |
| 206 | } |
| 207 | } |
| 208 | if len(out) == 0 { |
| 209 | return []check.Result{check.SkipR("web roots", "Document roots could not be read")} |
| 210 | } |
| 211 | return out |
| 212 | }, |
| 213 | }) |
| 214 | |
| 215 | check.Register(&check.Check{ |
| 216 | ID: "WEB-COR-001", Title: "Cross-Origin Resource Sharing Allows Any Origin With Credentials", Domain: check.DomainWeb, |
| 217 | Severity: check.Medium, Category: check.CatWebApp, Effort: check.EffortLow, |
| 218 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N", |
| 219 | Summary: "Checks the home page's CORS headers for a wildcard origin combined with credentials, or an origin of null.", |
| 220 | Rationale: "A permissive CORS policy can let any website read responses on behalf of a logged-in visitor, exposing personal data and tokens.", |
| 221 | Detection: "Reads Access-Control-Allow-Origin and Access-Control-Allow-Credentials on each site's home page. '*' with credentials true, or 'null', fails. Origin reflection needs crafted requests and is outside this read-only check.", |
| 222 | Fix: "Return Access-Control-Allow-Origin only for an explicit allow-list of trusted origins, never '*' or 'null' together with credentials, and add Vary: Origin.", |
| 223 | Verify: "curl -sI https://<site>/ | grep -i access-control", |
| 224 | Refs: []check.Ref{{Title: "OWASP WSTG-CLNT-07, Testing Cross Origin Resource Sharing", URL: "https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/11-Client-side_Testing/07-Testing_Cross_Origin_Resource_Sharing/"}, refMDN("Access-Control-Allow-Origin")}, |
| 225 | Standards: check.Standards{CWE: []string{"CWE-942"}, OWASP: []string{"A05:2021", "A01:2021"}, ASVS: []string{"14.5.3"}, WSTG: []string{"WSTG-CLNT-07"}, NIST: []string{"AC-4", "CM-6"}}, |
| 226 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 227 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 228 | o := strings.TrimSpace(st.Home.Header.Get("Access-Control-Allow-Origin")) |
| 229 | cred := strings.EqualFold(strings.TrimSpace(st.Home.Header.Get("Access-Control-Allow-Credentials")), "true") |
| 230 | if o == "null" || (o == "*" && cred) { |
| 231 | return check.FailR(st.Name, "Permissive CORS policy", "Access-Control-Allow-Origin: "+o, fmt.Sprintf("Access-Control-Allow-Credentials: %v", cred)) |
| 232 | } |
| 233 | return check.PassR(st.Name, "No permissive CORS policy on the home page") |
| 234 | }) |
| 235 | }, |
| 236 | }) |
| 237 | |
| 238 | check.Register(&check.Check{ |
| 239 | ID: "WEB-SEC-001", Title: "No security.txt Contact File", Domain: check.DomainWeb, |
| 240 | Severity: check.Info, Category: check.CatWebApp, Effort: check.EffortLow, |
| 241 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", |
| 242 | Summary: "Checks whether each site publishes /.well-known/security.txt (RFC 9116).", |
| 243 | Rationale: "security.txt tells researchers how to report a vulnerability. Without it, reports may go unheard or be disclosed publicly instead.", |
| 244 | Detection: "Requests /.well-known/security.txt and looks for a Contact: field. This is informational and does not affect the risk summary.", |
| 245 | Fix: "Publish /.well-known/security.txt with at least Contact: and Expires: fields (see securitytxt.org).", |
| 246 | Verify: "curl -s https://<site>/.well-known/security.txt", |
| 247 | Refs: []check.Ref{{Title: "RFC 9116, A File Format to Aid in Security Vulnerability Disclosure", URL: "https://www.rfc-editor.org/rfc/rfc9116.html"}}, |
| 248 | Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"RA-5(11)"}}, |
| 249 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 250 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 251 | r, err := st.Get(ctx, s, "/.well-known/security.txt") |
| 252 | if err == nil && r.Status == 200 && strings.Contains(strings.ToLower(r.Body), "contact:") { |
| 253 | return check.PassR(st.Name, "security.txt is published") |
| 254 | } |
| 255 | return check.FailR(st.Name, "No /.well-known/security.txt with a Contact field") |
| 256 | }) |
| 257 | }, |
| 258 | }) |
| 259 | } |
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). WEB-SEC-001 No security.txt Contact File (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-SEC-001.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_web_sec_001,
author = {{OpenVAPT}},
title = {{WEB-SEC-001 No security.txt Contact File}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-SEC-001.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.