Symfony Running in the Development Environment
Checks Symfony .env files for APP_ENV=dev or APP_DEBUG=1.
- Why It Matters
- The dev environment enables the web profiler and debug toolbar, which show requests, queries, configuration, the container and sometimes credentials to any visitor, and it skips the caches that production relies on.
- How It Detects
- Finds .env files that contain APP_SECRET (the Symfony marker) and fails when APP_ENV is dev or test, or APP_DEBUG is 1 or true. A .env.local or .env.prod override is not evaluated, so confirm the final value on the server.
- How to Fix
- Set APP_ENV=prod and APP_DEBUG=0 in .env (or .env.local), run composer dump-env prod, clear the cache (php bin/console cache:clear) and remove the web profiler bundle from production.
- How to Verify
php bin/console about shows Environment prod and Debug false.- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N- Control Category
- Information Exposure: CIS Controls v8 3, 16; NIST CSF 2.0 PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 16.7
- NIST CSF 2.0 Subcategory
- PR.PS-01
- OWASP WSTG v4.2
- WSTG-CONF-02, WSTG-ERRH-01
- CWE
- CWE-489, CWE-215
- OWASP Top 10
- A05:2021
- OWASP ASVS 4.0.3
- 14.3.2
- NIST SP 800-53 Rev. 5
- CM-6, SI-11
- References
- Symfony documentation, Configuring Symfony (environments)
Symfony documentation, How to Deploy a Symfony Application
OWASP ASVS 4.0.3, V14 Configuration
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-APP-003, or read it offline with openvapt explain WEB-APP-003.
internal/checks/web/frameworks.go · Lines 1-461View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/openvapt/openvapt/internal/check" |
| 13 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 14 | "github.com/openvapt/openvapt/internal/sys" |
| 15 | ) |
| 16 | |
| 17 | // Application framework configuration checks (WEB-APP). Laravel, Symfony, |
| 18 | // Rails, Spring Boot and Flask projects are found through the same project |
| 19 | // folders as the Django checks (collect.ProjectFiles). Configuration files |
| 20 | // hold secrets, so only the path and the name of the setting are reported, |
| 21 | // never a value; files under writable project folders are read without |
| 22 | // following symlinks. |
| 23 | |
| 24 | var ( |
| 25 | refLaravelDeploy = check.Ref{Title: "Laravel documentation, Deployment", URL: "https://laravel.com/docs/12.x/deployment"} |
| 26 | refLaravelConfig = check.Ref{Title: "Laravel documentation, Configuration (debug mode, environment files)", URL: "https://laravel.com/docs/12.x/configuration"} |
| 27 | refSymfonyEnv = check.Ref{Title: "Symfony documentation, Configuring Symfony (environments)", URL: "https://symfony.com/doc/current/configuration.html"} |
| 28 | refRailsSecurity = check.Ref{Title: "Ruby on Rails Guides, Securing Rails Applications", URL: "https://guides.rubyonrails.org/security.html"} |
| 29 | refRailsConfig = check.Ref{Title: "Ruby on Rails Guides, Configuring Rails Applications", URL: "https://guides.rubyonrails.org/configuring.html"} |
| 30 | refSpringAct = check.Ref{Title: "Spring Boot documentation, Actuator endpoints", URL: "https://docs.spring.io/spring-boot/reference/actuator/endpoints.html"} |
| 31 | refFlaskDebug = check.Ref{Title: "Flask documentation, Debug mode", URL: "https://flask.palletsprojects.com/en/stable/quickstart/#debug-mode"} |
| 32 | refASVSV14 = check.Ref{Title: "OWASP ASVS 4.0.3, V14 Configuration", URL: "https://github.com/OWASP/ASVS/blob/v4.0.3/4.0/en/0x22-V14-Config.md"} |
| 33 | refWSTGConf02 = check.Ref{Title: "OWASP WSTG-CONF-02, Test Application Platform Configuration", URL: "https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/02-Test_Application_Platform_Configuration/"} |
| 34 | refSecretsCS = check.Ref{Title: "OWASP Secrets Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html"} |
| 35 | refOWASPDepCheck = check.Ref{Title: "OWASP Vulnerable Dependency Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Vulnerable_Dependency_Management_Cheat_Sheet.html"} |
| 36 | ) |
| 37 | |
| 38 | // envFile is one dotenv file found in a project folder. |
| 39 | type envFile struct { |
| 40 | path string |
| 41 | vars map[string]string // upper-cased key -> value, comments removed |
| 42 | mode sys.FileInfo |
| 43 | } |
| 44 | |
| 45 | // parseDotenv reads KEY=value lines. Quotes are stripped; values are kept |
| 46 | // only in memory for comparisons and never reported. |
| 47 | func parseDotenv(text string) map[string]string { |
| 48 | out := map[string]string{} |
| 49 | for _, line := range strings.Split(text, "\n") { |
| 50 | t := strings.TrimSpace(line) |
| 51 | if t == "" || strings.HasPrefix(t, "#") { |
| 52 | continue |
| 53 | } |
| 54 | t = strings.TrimPrefix(t, "export ") |
| 55 | k, v, ok := strings.Cut(t, "=") |
| 56 | if !ok { |
| 57 | continue |
| 58 | } |
| 59 | v = strings.TrimSpace(v) |
| 60 | if q := v[:min(len(v), 1)]; q == `"` || q == `'` { |
| 61 | // Quoted value: keep everything up to the closing quote. |
| 62 | if end := strings.Index(v[1:], q); end >= 0 { |
| 63 | v = v[1 : end+1] |
| 64 | } else { |
| 65 | v = strings.Trim(v, q) |
| 66 | } |
| 67 | } else if i := strings.Index(v, " #"); i >= 0 { |
| 68 | v = strings.TrimSpace(v[:i]) |
| 69 | } |
| 70 | out[strings.ToUpper(strings.TrimSpace(k))] = v |
| 71 | } |
| 72 | return out |
| 73 | } |
| 74 | |
| 75 | // envFiles returns every .env file in the project folders that contains at |
| 76 | // least one of the marker keys (so a Laravel check never reads a Symfony |
| 77 | // file as Laravel and vice versa). |
| 78 | func envFiles(ctx context.Context, s sys.System, markers ...string) []envFile { |
| 79 | var out []envFile |
| 80 | for _, p := range collect.ProjectFiles(ctx, s, ".env") { |
| 81 | r, err := sys.ReadFileNoFollow(ctx, s, p) |
| 82 | if err != nil { |
| 83 | continue |
| 84 | } |
| 85 | vars := parseDotenv(string(r.Data)) |
| 86 | hit := len(markers) == 0 |
| 87 | for _, m := range markers { |
| 88 | if _, ok := vars[m]; ok { |
| 89 | hit = true |
| 90 | } |
| 91 | } |
| 92 | if !hit { |
| 93 | continue |
| 94 | } |
| 95 | st, _ := s.Stat(p) |
| 96 | out = append(out, envFile{path: p, vars: vars, mode: st}) |
| 97 | } |
| 98 | return out |
| 99 | } |
| 100 | |
| 101 | func truthy(v string) bool { |
| 102 | switch strings.ToLower(strings.TrimSpace(v)) { |
| 103 | case "true", "1", "yes", "on": |
| 104 | return true |
| 105 | } |
| 106 | return false |
| 107 | } |
| 108 | |
| 109 | // unreadable is the verdict a judge returns when its file could not be read; |
| 110 | // perFile reports it as Incomplete rather than Fail. |
| 111 | const unreadable = "could not be read" |
| 112 | |
| 113 | // perFile turns per-path verdicts into results, or a Skip when nothing was found. |
| 114 | func perFile(paths []string, judge func(string) []string, failMsg, skipMsg string) []check.Result { |
| 115 | if len(paths) == 0 { |
| 116 | return []check.Result{check.SkipR("projects", skipMsg)} |
| 117 | } |
| 118 | var out []check.Result |
| 119 | for _, p := range paths { |
| 120 | if bad := judge(p); len(bad) == 1 && bad[0] == unreadable { |
| 121 | out = append(out, check.IncompleteR(p, "File could not be read")) |
| 122 | } else if len(bad) > 0 { |
| 123 | out = append(out, check.FailR(p, failMsg, bad...)) |
| 124 | } else { |
| 125 | out = append(out, check.PassR(p, "No issue found")) |
| 126 | } |
| 127 | } |
| 128 | return out |
| 129 | } |
| 130 | |
| 131 | func init() { |
| 132 | check.Register(&check.Check{ |
| 133 | ID: "WEB-APP-001", Title: "Laravel Debug Mode On or Application Key Missing", Domain: check.DomainWeb, |
| 134 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortLow, |
| 135 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 136 | Summary: "Checks Laravel .env files for APP_DEBUG=true or an empty APP_KEY.", |
| 137 | Rationale: "With APP_DEBUG on, Laravel's error page prints the environment, including database and mail passwords, to any visitor who triggers an error. APP_KEY encrypts sessions and cookies; with it empty or default the application refuses to run or signs nothing.", |
| 138 | Detection: "Finds .env files in /srv, /opt, /var/www, home folders and web roots (three folders deep) that contain APP_KEY (the Laravel marker) and fails when APP_DEBUG is true or APP_KEY is empty. Values are never reported.", |
| 139 | Fix: "Set APP_DEBUG=false and APP_ENV=production in .env, run php artisan key:generate if APP_KEY is empty, then php artisan config:cache. Keep .env outside the web root (Laravel's public/ folder) and readable only by the application user.", |
| 140 | Verify: "grep -E '^APP_(DEBUG|KEY)=' .env shows APP_DEBUG=false and a base64 key.", |
| 141 | Refs: []check.Ref{refLaravelDeploy, refLaravelConfig, refASVSV14}, |
| 142 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-209", "CWE-321"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.3.2", "2.10.4"}, WSTG: []string{"WSTG-CONF-02", "WSTG-ERRH-01"}, NIST: []string{"CM-6", "SI-11", "IA-5"}}, |
| 143 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 144 | files := envFiles(ctx, s, "APP_KEY") |
| 145 | var paths []string |
| 146 | byPath := map[string]envFile{} |
| 147 | for _, f := range files { |
| 148 | paths = append(paths, f.path) |
| 149 | byPath[f.path] = f |
| 150 | } |
| 151 | return perFile(paths, func(p string) []string { |
| 152 | f := byPath[p] |
| 153 | var bad []string |
| 154 | if truthy(f.vars["APP_DEBUG"]) { |
| 155 | bad = append(bad, "APP_DEBUG is true") |
| 156 | } |
| 157 | if strings.TrimSpace(f.vars["APP_KEY"]) == "" { |
| 158 | bad = append(bad, "APP_KEY is empty") |
| 159 | } |
| 160 | return bad |
| 161 | }, "Laravel runs in debug mode or without an application key", "No Laravel .env files found") |
| 162 | }, |
| 163 | }) |
| 164 | |
| 165 | check.Register(&check.Check{ |
| 166 | ID: "WEB-APP-002", Title: "Application .env File Readable by All Users", Domain: check.DomainWeb, |
| 167 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortLow, |
| 168 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", |
| 169 | Summary: "Checks the permission bits of framework .env files for world-read access.", |
| 170 | Rationale: ".env files hold database passwords, API tokens and signing keys in plain text. A world-readable .env hands every account on the server, including other websites' PHP-FPM pools, the keys to this application.", |
| 171 | Detection: "Stats each .env file found in the project folders (metadata only) and fails when the 'other read' permission bit is set.", |
| 172 | Fix: "chmod 640 .env && chown <app-user>:<web-group> .env (or 600 when the application runs as its own user). Rotate the secrets if other people have had shell access.", |
| 173 | Verify: "stat -c '%a %U:%G %n' .env shows 640 or 600.", |
| 174 | Refs: []check.Ref{refSecretsCS, refASVSV14}, |
| 175 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-312"}, OWASP: []string{"A05:2021", "A02:2021"}, ASVS: []string{"14.1.3"}, WSTG: []string{"WSTG-CONF-09"}, NIST: []string{"AC-3", "SC-28"}}, |
| 176 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 177 | files := envFiles(ctx, s) |
| 178 | var paths []string |
| 179 | byPath := map[string]envFile{} |
| 180 | for _, f := range files { |
| 181 | paths = append(paths, f.path) |
| 182 | byPath[f.path] = f |
| 183 | } |
| 184 | return perFile(paths, func(p string) []string { |
| 185 | if byPath[p].mode.Mode.Perm()&0o004 != 0 { |
| 186 | return []string{fmt.Sprintf("mode %04o is readable by all users", byPath[p].mode.Mode.Perm())} |
| 187 | } |
| 188 | return nil |
| 189 | }, ".env file is readable by all users", "No .env files found in the project folders") |
| 190 | }, |
| 191 | }) |
| 192 | |
| 193 | check.Register(&check.Check{ |
| 194 | ID: "WEB-APP-003", Title: "Symfony Running in the Development Environment", Domain: check.DomainWeb, |
| 195 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortLow, |
| 196 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 197 | Summary: "Checks Symfony .env files for APP_ENV=dev or APP_DEBUG=1.", |
| 198 | Rationale: "The dev environment enables the web profiler and debug toolbar, which show requests, queries, configuration, the container and sometimes credentials to any visitor, and it skips the caches that production relies on.", |
| 199 | Detection: "Finds .env files that contain APP_SECRET (the Symfony marker) and fails when APP_ENV is dev or test, or APP_DEBUG is 1 or true. A .env.local or .env.prod override is not evaluated, so confirm the final value on the server.", |
| 200 | Fix: "Set APP_ENV=prod and APP_DEBUG=0 in .env (or .env.local), run composer dump-env prod, clear the cache (php bin/console cache:clear) and remove the web profiler bundle from production.", |
| 201 | Verify: "php bin/console about shows Environment prod and Debug false.", |
| 202 | Refs: []check.Ref{refSymfonyEnv, {Title: "Symfony documentation, How to Deploy a Symfony Application", URL: "https://symfony.com/doc/current/deployment.html"}, refASVSV14}, |
| 203 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-215"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.3.2"}, WSTG: []string{"WSTG-CONF-02", "WSTG-ERRH-01"}, NIST: []string{"CM-6", "SI-11"}}, |
| 204 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 205 | files := envFiles(ctx, s, "APP_SECRET") |
| 206 | var paths []string |
| 207 | byPath := map[string]envFile{} |
| 208 | for _, f := range files { |
| 209 | paths = append(paths, f.path) |
| 210 | byPath[f.path] = f |
| 211 | } |
| 212 | return perFile(paths, func(p string) []string { |
| 213 | f := byPath[p] |
| 214 | var bad []string |
| 215 | if e := strings.ToLower(f.vars["APP_ENV"]); e == "dev" || e == "test" { |
| 216 | bad = append(bad, "APP_ENV is "+e) |
| 217 | } |
| 218 | if truthy(f.vars["APP_DEBUG"]) { |
| 219 | bad = append(bad, "APP_DEBUG is on") |
| 220 | } |
| 221 | return bad |
| 222 | }, "Symfony runs in the development environment", "No Symfony .env files found") |
| 223 | }, |
| 224 | }) |
| 225 | |
| 226 | check.Register(&check.Check{ |
| 227 | ID: "WEB-APP-004", Title: "Rails Master Key or Secrets File Exposed", Domain: check.DomainWeb, |
| 228 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 229 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N", |
| 230 | Summary: "Checks Rails config/master.key, credentials key files and secrets.yml for world-read permissions or a location inside a web root.", |
| 231 | Rationale: "master.key decrypts credentials.yml.enc, which holds every production secret, and secrets.yml holds the secret_key_base that signs sessions. Anyone who reads them can forge sessions and read the database password. Both are meant to stay out of version control and out of any public folder.", |
| 232 | Detection: "Finds config/master.key, config/credentials/*.key and config/secrets.yml in the project folders and fails when the file is readable by others or sits inside a web document root. Contents are never read.", |
| 233 | Fix: "chmod 600 config/master.key config/credentials/*.key; keep them out of git (.gitignore) and out of public/; load the key from RAILS_MASTER_KEY in the environment on servers. Rotate the credentials if the key was ever exposed.", |
| 234 | Verify: "stat -c '%a %n' config/master.key shows 600, and git ls-files config/master.key prints nothing.", |
| 235 | Refs: []check.Ref{refRailsSecurity, {Title: "Ruby on Rails Guides, Custom Credentials", URL: "https://guides.rubyonrails.org/security.html#custom-credentials"}, refSecretsCS}, |
| 236 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-312", "CWE-321"}, OWASP: []string{"A02:2021", "A05:2021"}, ASVS: []string{"2.10.4", "14.1.3"}, WSTG: []string{"WSTG-CONF-09"}, NIST: []string{"AC-3", "SC-28", "IA-5"}}, |
| 237 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 238 | paths := collect.ProjectFiles(ctx, s, "config/master.key", "config/credentials/*.key", "config/secrets.yml") |
| 239 | roots := collect.DocRoots(ctx, s) |
| 240 | return perFile(paths, func(p string) []string { |
| 241 | var bad []string |
| 242 | if st, err := s.Stat(p); err == nil && st.Mode.Perm()&0o004 != 0 { |
| 243 | bad = append(bad, fmt.Sprintf("mode %04o is readable by all users", st.Mode.Perm())) |
| 244 | } |
| 245 | for _, r := range roots { |
| 246 | if strings.HasPrefix(p, r+"/") { |
| 247 | bad = append(bad, "inside the web root "+r) |
| 248 | } |
| 249 | } |
| 250 | return bad |
| 251 | }, "Rails key or secrets file is exposed", "No Rails master.key or secrets.yml found") |
| 252 | }, |
| 253 | }) |
| 254 | |
| 255 | check.Register(&check.Check{ |
| 256 | ID: "WEB-APP-005", Title: "Rails Production Does Not Force HTTPS", Domain: check.DomainWeb, |
| 257 | Severity: check.Medium, Category: check.CatWebApp, Effort: check.EffortLow, |
| 258 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N", |
| 259 | Summary: "Checks config/environments/production.rb for config.force_ssl = true.", |
| 260 | Rationale: "force_ssl redirects plain HTTP to HTTPS, marks cookies Secure and sends Strict-Transport-Security. Without it a session cookie can travel over HTTP once and be read on the network, even when the site also works over HTTPS.", |
| 261 | Detection: "Finds config/environments/production.rb in the project folders and fails when config.force_ssl is false or absent (comments removed). An assume_ssl-only setup still fails because cookies are not marked Secure.", |
| 262 | Fix: "Set config.force_ssl = true in config/environments/production.rb (and config.assume_ssl = true behind a TLS-terminating proxy), then restart the application.", |
| 263 | Verify: "grep -n force_ssl config/environments/production.rb shows true, and curl -I http://<site>/ returns a 301 to https.", |
| 264 | Refs: []check.Ref{refRailsConfig, refRailsSecurity}, |
| 265 | Standards: check.Standards{CWE: []string{"CWE-319", "CWE-614"}, OWASP: []string{"A02:2021", "A05:2021"}, ASVS: []string{"9.1.1", "3.4.1"}, WSTG: []string{"WSTG-CRYP-03"}, NIST: []string{"SC-8", "CM-6"}}, |
| 266 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 267 | paths := collect.ProjectFiles(ctx, s, "config/environments/production.rb") |
| 268 | forceOn := regexp.MustCompile(`(?m)^\s*config\.force_ssl\s*=\s*true\b`) |
| 269 | rubyComment := regexp.MustCompile(`(?m)#[^\n]*`) |
| 270 | return perFile(paths, func(p string) []string { |
| 271 | r, err := sys.ReadFileNoFollow(ctx, s, p) |
| 272 | if err != nil { |
| 273 | return []string{unreadable} |
| 274 | } |
| 275 | if !forceOn.MatchString(rubyComment.ReplaceAllString(string(r.Data), "")) { |
| 276 | return []string{"config.force_ssl is not true"} |
| 277 | } |
| 278 | return nil |
| 279 | }, "Rails production does not force HTTPS", "No Rails production configuration found") |
| 280 | }, |
| 281 | }) |
| 282 | |
| 283 | check.Register(&check.Check{ |
| 284 | ID: "WEB-APP-006", Title: "Spring Boot Actuator Endpoints Fully Exposed", Domain: check.DomainWeb, |
| 285 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortLow, |
| 286 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 287 | Summary: "Checks Spring Boot application properties for management.endpoints.web.exposure.include=* and probes the local site for an open /actuator/env.", |
| 288 | Rationale: "Actuator's env, configprops, heapdump and threaddump endpoints print environment variables, configuration (including credentials) and process memory. Exposing them all over HTTP without authentication is one of the most common Java application leaks.", |
| 289 | Detection: "Reads application.properties, application.yml and their -prod variants in the project folders for exposure.include set to * (or a list containing env, heapdump or configprops) with no management.server.port moving them off the public port. Also requests /actuator/env on each local site and fails on a JSON reply containing propertySources.", |
| 290 | Fix: "Set management.endpoints.web.exposure.include=health,info (and exclude env,heapdump,configprops), move management.server.port to a port that is not published, and require authentication for every actuator endpoint with Spring Security.", |
| 291 | Verify: "curl -s -o /dev/null -w '%{http_code}' https://<site>/actuator/env prints 401, 403 or 404.", |
| 292 | Refs: []check.Ref{refSpringAct, {Title: "Spring Boot documentation, Securing HTTP endpoints", URL: "https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.security"}, refWSTGConf02}, |
| 293 | Standards: check.Standards{CWE: []string{"CWE-306", "CWE-497"}, OWASP: []string{"A05:2021", "A01:2021"}, ASVS: []string{"14.3.2", "4.3.2"}, WSTG: []string{"WSTG-CONF-02", "WSTG-CONF-05"}, NIST: []string{"CM-7", "AC-3", "SI-11"}}, |
| 294 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 295 | paths := collect.ProjectFiles(ctx, s, "application.properties", "application.yml", "application.yaml", |
| 296 | "application-prod.properties", "application-prod.yml", "config/application.properties", "config/application.yml") |
| 297 | propAll := regexp.MustCompile(`(?m)^\s*management\.endpoints\.web\.exposure\.include\s*[=:]\s*["']?(\*|[^\n]*\b(env|heapdump|configprops)\b)`) |
| 298 | yamlAll := regexp.MustCompile(`(?s)exposure:\s*\n\s*include:\s*["']?(\*|[^\n]*\b(env|heapdump|configprops)\b)`) |
| 299 | mgmtPort := regexp.MustCompile(`(?m)^\s*management\.server\.port\s*[=:]|(?s)management:\s*\n(\s+.*\n)*?\s+server:\s*\n\s+port:`) |
| 300 | out := perFile(paths, func(p string) []string { |
| 301 | r, err := sys.ReadFileNoFollow(ctx, s, p) |
| 302 | if err != nil { |
| 303 | return []string{unreadable} |
| 304 | } |
| 305 | t := string(r.Data) |
| 306 | if (propAll.MatchString(t) || yamlAll.MatchString(t)) && !mgmtPort.MatchString(t) { |
| 307 | return []string{"management.endpoints.web.exposure.include exposes sensitive actuator endpoints on the application port"} |
| 308 | } |
| 309 | return nil |
| 310 | }, "Spring Boot actuator endpoints are fully exposed", "No Spring Boot application properties found") |
| 311 | var sites []check.Result |
| 312 | for _, st := range Sites(ctx, s) { |
| 313 | r, err := st.Get(ctx, s, "/actuator/env") |
| 314 | if err == nil && r.Status == 200 && !HTML(r) && strings.Contains(r.Body, "propertySources") { |
| 315 | sites = append(sites, check.FailR(st.Name, "/actuator/env answers without authentication")) |
| 316 | } |
| 317 | } |
| 318 | if len(sites) > 0 { |
| 319 | if len(out) == 1 && out[0].Status == check.Skip { |
| 320 | return sites |
| 321 | } |
| 322 | return append(out, sites...) |
| 323 | } |
| 324 | return out |
| 325 | }, |
| 326 | }) |
| 327 | |
| 328 | check.Register(&check.Check{ |
| 329 | ID: "WEB-APP-007", Title: "Flask Debug Mode Enabled in Application Code", Domain: check.DomainWeb, |
| 330 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortLow, |
| 331 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", |
| 332 | Summary: "Looks for app.run(debug=True) or app.debug = True in Flask entry files. A heuristic; the service check WEB-PY-004 covers the environment switches.", |
| 333 | Rationale: "Debug mode in code follows the application wherever it is deployed. If the file is ever run directly instead of through a WSGI server, the Werkzeug debugger and its code console come up for every visitor.", |
| 334 | Detection: "Reads app.py, wsgi.py, run.py, main.py and server.py in the project folders (files that import flask) and flags debug=True inside app.run(...) or an app.debug = True assignment, ignoring commented lines. Rated Low because a production WSGI server ignores app.run().", |
| 335 | Fix: "Remove debug=True from app.run() and read it from the environment for local work only (app.run(debug=os.environ.get('FLASK_DEBUG') == '1')); serve production with Gunicorn or uWSGI, which never call app.run().", |
| 336 | Verify: "grep -n 'debug' app.py shows no hard-coded True.", |
| 337 | Refs: []check.Ref{refFlaskDebug, {Title: "Flask documentation, Deploying to Production", URL: "https://flask.palletsprojects.com/en/stable/deploying/"}}, |
| 338 | Standards: check.Standards{CWE: []string{"CWE-489", "CWE-215"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.3.2"}, WSTG: []string{"WSTG-CONF-02"}, NIST: []string{"CM-6", "CM-7"}}, |
| 339 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 340 | var paths []string |
| 341 | texts := map[string]string{} |
| 342 | flaskImport := regexp.MustCompile(`(?m)^\s*(from\s+flask\s+import|import\s+flask)`) |
| 343 | for _, p := range collect.ProjectFiles(ctx, s, "app.py", "wsgi.py", "run.py", "main.py", "server.py") { |
| 344 | r, err := sys.ReadFileNoFollow(ctx, s, p) |
| 345 | if err != nil || !flaskImport.Match(r.Data) { |
| 346 | continue |
| 347 | } |
| 348 | paths = append(paths, p) |
| 349 | texts[p] = string(r.Data) |
| 350 | } |
| 351 | sort.Strings(paths) |
| 352 | pyComment := regexp.MustCompile(`(?m)#[^\n]*`) |
| 353 | runDebug := regexp.MustCompile(`\.run\s*\([^)]*\bdebug\s*=\s*True\b`) |
| 354 | attrDebug := regexp.MustCompile(`(?m)^\s*\w+\.debug\s*=\s*True\b`) |
| 355 | return perFile(paths, func(p string) []string { |
| 356 | t := pyComment.ReplaceAllString(texts[p], "") |
| 357 | var bad []string |
| 358 | if runDebug.MatchString(t) { |
| 359 | bad = append(bad, "app.run(debug=True)") |
| 360 | } |
| 361 | if attrDebug.MatchString(t) { |
| 362 | bad = append(bad, "app.debug = True") |
| 363 | } |
| 364 | return bad |
| 365 | }, "Flask debug mode is enabled in code", "No Flask entry files found") |
| 366 | }, |
| 367 | }) |
| 368 | |
| 369 | check.Register(&check.Check{ |
| 370 | ID: "WEB-APP-008", Title: "Dependency Lock Files Older Than a Year", Domain: check.DomainWeb, |
| 371 | Severity: check.Info, Category: check.CatPatch, Effort: check.EffortMedium, |
| 372 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", |
| 373 | Summary: "Reports composer.lock, package-lock.json, yarn.lock, Gemfile.lock and poetry.lock files not modified for 365 days or more (dependency freshness). Informational.", |
| 374 | Rationale: "A lock file pins exact library versions. One untouched for a year means the application has had no dependency updates in that time, so every library vulnerability published since is still present. The check cannot tell whether a specific vulnerability applies; the known-vulnerability checks and composer audit or npm audit do that.", |
| 375 | Detection: "Stats each lock file found in the project folders (metadata only) and reports those whose modification time is 365 days or older. Files whose time cannot be read are reported as incomplete.", |
| 376 | Fix: "Run composer update / npm update / bundle update / poetry update in a test environment, run the audit command of each ecosystem (composer audit, npm audit, bundle audit, pip-audit) and deploy. Schedule dependency updates at least quarterly.", |
| 377 | Verify: "stat -c '%y %n' composer.lock package-lock.json shows a recent date.", |
| 378 | Refs: []check.Ref{refOWASPDepCheck, {Title: "OWASP Top 10 2021, A06 Vulnerable and Outdated Components", URL: "https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/"}}, |
| 379 | Standards: check.Standards{CWE: []string{"CWE-1104", "CWE-1395"}, OWASP: []string{"A06:2021"}, ASVS: []string{"14.2.1"}, NIST: []string{"SI-2", "SA-22"}}, |
| 380 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 381 | paths := collect.ProjectFiles(ctx, s, "composer.lock", "package-lock.json", "yarn.lock", "Gemfile.lock", "poetry.lock") |
| 382 | if len(paths) == 0 { |
| 383 | return []check.Result{check.SkipR("projects", "No dependency lock files found in the project folders")} |
| 384 | } |
| 385 | cutoff := time.Now().Add(-365 * 24 * time.Hour) |
| 386 | var out []check.Result |
| 387 | for _, p := range paths { |
| 388 | st, err := s.Stat(p) |
| 389 | if err != nil || st.ModTime.IsZero() { |
| 390 | out = append(out, check.IncompleteR(p, "Modification time could not be read")) |
| 391 | continue |
| 392 | } |
| 393 | days := int(time.Since(st.ModTime).Hours() / 24) |
| 394 | if st.ModTime.Before(cutoff) { |
| 395 | out = append(out, check.FailR(p, fmt.Sprintf("Lock file last changed %d days ago", days))) |
| 396 | } else { |
| 397 | out = append(out, check.PassR(p, fmt.Sprintf("Lock file changed %d days ago", days))) |
| 398 | } |
| 399 | } |
| 400 | return out |
| 401 | }, |
| 402 | }) |
| 403 | |
| 404 | check.Register(&check.Check{ |
| 405 | ID: "WEB-APP-009", Title: "Git Repository Stored Inside a Web Root", Domain: check.DomainWeb, |
| 406 | Severity: check.Medium, Category: check.CatExposure, Effort: check.EffortLow, |
| 407 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", |
| 408 | Summary: "Looks on disk for a .git folder directly inside each web document root.", |
| 409 | Rationale: "A .git folder in the document root is one web server rule away from being downloadable, which rebuilds the full source and its history, often with committed credentials. WEB-EXP-001 tests whether it is already reachable; this check finds it before a configuration change exposes it.", |
| 410 | Detection: "Stats <docroot>/.git/HEAD for each local document root (metadata only). Fails when it exists, even if the web server currently blocks dot-folders.", |
| 411 | Fix: "Deploy from a build step that copies only the needed files (git archive, rsync --exclude .git, or a CI artefact), or point the document root at a public/ sub-folder as Laravel, Symfony and Rails expect. Remove the .git folder from the web root and rotate any secret ever committed.", |
| 412 | Verify: "ls -d <docroot>/.git prints nothing.", |
| 413 | Refs: []check.Ref{{Title: "OWASP WSTG-CONF-04, Review Old Backup and Unreferenced Files", URL: "https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/04-Review_Old_Backup_and_Unreferenced_Files_for_Sensitive_Information/"}, refSecretsCS}, |
| 414 | Standards: check.Standards{CWE: []string{"CWE-527", "CWE-538"}, OWASP: []string{"A05:2021"}, ASVS: []string{"12.5.1"}, WSTG: []string{"WSTG-CONF-04"}, NIST: []string{"CM-7", "SC-28"}}, |
| 415 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 416 | roots := collect.DocRoots(ctx, s) |
| 417 | if len(roots) == 0 { |
| 418 | return []check.Result{check.SkipR("web roots", "No document roots found in the local web server configuration")} |
| 419 | } |
| 420 | var out []check.Result |
| 421 | for _, root := range roots { |
| 422 | if _, err := s.Stat(root + "/.git/HEAD"); err == nil { |
| 423 | out = append(out, check.FailR(root, "A .git repository is stored inside the web root", root+"/.git")) |
| 424 | } else { |
| 425 | out = append(out, check.PassR(root, "No .git folder in the web root")) |
| 426 | } |
| 427 | } |
| 428 | return out |
| 429 | }, |
| 430 | }) |
| 431 | |
| 432 | check.Register(&check.Check{ |
| 433 | ID: "WEB-APP-010", Title: "Framework Writable Folders Open to All Users", Domain: check.DomainWeb, |
| 434 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortLow, |
| 435 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L", |
| 436 | Summary: "Checks Laravel and Symfony cache, log and storage folders for world-write permission.", |
| 437 | Rationale: "chmod -R 777 storage is a common fix for permission errors. Laravel's bootstrap/cache and Symfony's var/cache hold compiled PHP that the framework includes on every request, so any local user who can write there runs code as the web server. Logs and sessions in storage are readable and forgeable too.", |
| 438 | Detection: "For each Laravel (.env with APP_KEY) or Symfony (.env with APP_SECRET) project, stats storage, storage/logs, storage/framework, bootstrap/cache, var/cache and var/log next to the .env file (metadata only) and fails when any has the 'other write' bit set.", |
| 439 | Fix: "chmod -R o-w storage bootstrap/cache var and give the web server group write access instead: chgrp -R www-data storage bootstrap/cache && chmod -R g+w storage bootstrap/cache.", |
| 440 | Verify: "find storage bootstrap/cache var -perm -0002 -type d prints nothing.", |
| 441 | Refs: []check.Ref{refLaravelDeploy, {Title: "Symfony documentation, Setting up or Fixing File Permissions", URL: "https://symfony.com/doc/current/setup/file_permissions.html"}, {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/"}}, |
| 442 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-94"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.1.3"}, WSTG: []string{"WSTG-CONF-09"}, NIST: []string{"AC-3", "AC-6", "CM-5"}}, |
| 443 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 444 | files := envFiles(ctx, s, "APP_KEY", "APP_SECRET") |
| 445 | var paths []string |
| 446 | for _, f := range files { |
| 447 | paths = append(paths, f.path) |
| 448 | } |
| 449 | return perFile(paths, func(p string) []string { |
| 450 | dir := filepath.Dir(p) |
| 451 | var bad []string |
| 452 | for _, sub := range []string{"storage", "storage/logs", "storage/framework", "bootstrap/cache", "var/cache", "var/log"} { |
| 453 | if st, err := s.Stat(dir + "/" + sub); err == nil && st.Mode.Perm()&0o002 != 0 { |
| 454 | bad = append(bad, fmt.Sprintf("%04o %s", st.Mode.Perm(), dir+"/"+sub)) |
| 455 | } |
| 456 | } |
| 457 | return bad |
| 458 | }, "Framework cache or storage folders are world-writable", "No Laravel or Symfony projects found") |
| 459 | }, |
| 460 | }) |
| 461 | } |
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-APP-003 Symfony Running in the Development Environment (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-APP-003.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_app_003,
author = {{OpenVAPT}},
title = {{WEB-APP-003 Symfony Running in the Development Environment}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-APP-003.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.