├── tests ├── __init__.py ├── core │ ├── __init__.py │ ├── test_variable.py │ ├── test_context.py │ └── test_regexp.py ├── directives │ ├── __init__.py │ ├── test_directive.py │ └── test_block.py ├── parser │ ├── __init__.py │ └── test_nginx_parser.py ├── plugins │ ├── __init__.py │ ├── simply │ │ ├── http_splitting │ │ │ ├── return_403_fp.conf │ │ │ ├── add_header_uri.conf │ │ │ ├── rewrite_uri.conf │ │ │ ├── config.json │ │ │ ├── rewrite_uri_after_var.conf │ │ │ ├── return_request_uri_fp.conf │ │ │ ├── proxy_pass_ducument_uri.conf │ │ │ ├── proxy_set_header_ducument_uri.conf │ │ │ ├── rewrite_extract_fp.conf │ │ │ ├── proxy_pass_cr_fp.conf │ │ │ ├── proxy_pass_lf.conf │ │ │ ├── proxy_from_location_var.conf │ │ │ ├── dont_report_not_resolved_var_fp.conf │ │ │ ├── proxy_from_location_var_var.conf │ │ │ ├── proxy_from_location_var_var_fp.conf │ │ │ └── proxy_from_location_var_var_var.conf │ │ ├── ssrf │ │ │ ├── config.json │ │ │ ├── used_arg.conf │ │ │ ├── single_var.conf │ │ │ ├── request_uri_fp.conf │ │ │ ├── have_internal_fp.conf │ │ │ ├── not_host_var_fp.conf │ │ │ ├── host_w_const_start.conf │ │ │ ├── host_w_const_start_arg.conf │ │ │ ├── scheme_var.conf │ │ │ ├── request_uri_var_fp.conf │ │ │ ├── vars_from_loc.conf │ │ │ └── with_const_scheme.conf │ │ ├── host_spoofing │ │ │ ├── http_fp.conf │ │ │ ├── config.json │ │ │ ├── http_host.conf │ │ │ ├── some_arg.conf │ │ │ └── http_host_diff_case.conf │ │ ├── add_header_multiline │ │ │ ├── add_header_fp.conf │ │ │ ├── config.json │ │ │ ├── more_set_headers_replace_fp.conf │ │ │ ├── more_set_headers_replace.conf │ │ │ ├── more_set_headers_status_fp.conf │ │ │ ├── more_set_headers_type_fp.conf │ │ │ ├── more_set_headers_fp.conf │ │ │ ├── more_set_headers.conf │ │ │ ├── add_header.conf │ │ │ └── more_set_headers_multiple.conf │ │ ├── valid_referers │ │ │ ├── config.json │ │ │ ├── none_first.conf │ │ │ ├── wo_none_fp.conf │ │ │ ├── none_middle.conf │ │ │ └── none_last.conf │ │ ├── origins │ │ │ ├── config.json │ │ │ ├── origin.conf │ │ │ ├── origin_fp.conf │ │ │ ├── referer.conf │ │ │ ├── referer_fp.conf │ │ │ ├── origin_w_slash_fp.conf │ │ │ ├── referer_subdomain.conf │ │ │ ├── origin_w_slash_anchored_fp.conf │ │ │ ├── referer_subdomain_fp.conf │ │ │ ├── structure_dot.conf │ │ │ ├── structure_fp.conf │ │ │ ├── structure_prefix.conf │ │ │ ├── structure_suffix.conf │ │ │ ├── origin_https.conf │ │ │ ├── origin_wo_slash.conf │ │ │ ├── origin_https_fp.conf │ │ │ ├── metrika.conf │ │ │ └── webvisor.conf │ │ ├── add_header_redefinition │ │ │ ├── config.json │ │ │ ├── non_block_fp.conf │ │ │ ├── if_replaces.conf │ │ │ ├── not_secure_both_fp.conf │ │ │ ├── location_replaces.conf │ │ │ ├── not_secure_outer_fp.conf │ │ │ ├── step_replaces.conf │ │ │ ├── duplicate_fp.conf │ │ │ └── nested_block.conf │ │ └── alias_traversal │ │ │ ├── config.json │ │ │ ├── simple.conf │ │ │ ├── simple_fp.conf │ │ │ ├── not_slashed_alias.conf │ │ │ ├── slashed_alias.conf │ │ │ ├── not_slashed_alias_fp.conf │ │ │ ├── slashed_alias_fp.conf │ │ │ ├── nested.conf │ │ │ └── nested_fp.conf │ └── test_simply.py ├── asserts.py └── utils.py ├── gixy ├── cli │ ├── __init__.py │ ├── main.py │ └── argparser.py ├── core │ ├── __init__.py │ ├── sre_parse │ │ ├── __init__.py │ │ └── sre_constants.py │ ├── exceptions.py │ ├── utils.py │ ├── severity.py │ ├── issue.py │ ├── config.py │ ├── context.py │ ├── plugins_manager.py │ ├── manager.py │ └── variable.py ├── parser │ ├── __init__.py │ ├── nginx_parser.py │ └── raw_parser.py ├── utils │ ├── __init__.py │ └── text.py ├── plugins │ ├── __init__.py │ ├── valid_referers.py │ ├── host_spoofing.py │ ├── plugin.py │ ├── alias_traversal.py │ ├── add_header_multiline.py │ ├── http_splitting.py │ ├── ssrf.py │ ├── add_header_redefinition.py │ └── origins.py ├── __init__.py ├── formatters │ ├── text.py │ ├── console.py │ ├── _jinja.py │ ├── __init__.py │ ├── json.py │ ├── templates │ │ ├── text.j2 │ │ └── console.j2 │ └── base.py └── directives │ ├── __init__.py │ ├── directive.py │ └── block.py ├── MANIFEST.in ├── docs ├── logo.png ├── ru │ └── plugins │ │ ├── aliastraversal.md │ │ ├── addheadermultiline.md │ │ ├── validreferers.md │ │ ├── hostspoofing.md │ │ ├── origins.md │ │ ├── addheaderredefinition.md │ │ ├── httpsplitting.md │ │ └── ssrf.md └── en │ └── plugins │ ├── addheadermultiline.md │ ├── aliastraversal.md │ ├── validreferers.md │ ├── hostspoofing.md │ ├── origins.md │ ├── httpsplitting.md │ ├── addheaderredefinition.md │ └── ssrf.md ├── requirements.dev.txt ├── requirements.txt ├── Dockerfile ├── AUTHORS ├── Makefile ├── .editorconfig ├── tox.ini ├── .travis.yml ├── .dockerignore ├── .gitignore ├── setup.py ├── CONTRIBUTING.md ├── rpm ├── gixy.spec └── python-argparse.spec ├── README.md └── README.RU.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/directives/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gixy/core/sre_parse/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include gixy/formatters/templates/* 2 | graft tests 3 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/return_403_fp.conf: -------------------------------------------------------------------------------- 1 | return 403; -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/gixy/master/docs/logo.png -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_fp.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Host $host; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/add_header_uri.conf: -------------------------------------------------------------------------------- 1 | add_header X-Uri $uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_uri.conf: -------------------------------------------------------------------------------- 1 | rewrite ^ http://some$uri; -------------------------------------------------------------------------------- /gixy/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidConfiguration(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/add_header_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Foo foo; -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "MEDIUM" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_host.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Host $http_host; -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/some_arg.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header host $arg_host; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "LOW" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": ["MEDIUM", "HIGH"] 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "MEDIUM" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_host_diff_case.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header HoSt $http_host; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_uri_after_var.conf: -------------------------------------------------------------------------------- 1 | return 301 https://$host$uri; -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/used_arg.conf: -------------------------------------------------------------------------------- 1 | location /proxy/ { 2 | proxy_pass $arg_some; 3 | } -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | nose>=1.3.7 2 | mock>=2.0.0 3 | coverage>=4.3 4 | flake8>=3.2 5 | tox>=2.7.0 -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": ["MEDIUM", "HIGH"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/simple.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/return_request_uri_fp.conf: -------------------------------------------------------------------------------- 1 | return 301 https://some$request_uri; -------------------------------------------------------------------------------- /gixy/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from gixy.core import severity 4 | 5 | version = '0.1.21' 6 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/simple_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/yandex.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_first.conf: -------------------------------------------------------------------------------- 1 | valid_referers none server_names *.webvisor.com; -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/not_slashed_alias.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/slashed_alias.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_ducument_uri.conf: -------------------------------------------------------------------------------- 1 | proxy_pass http://upstream$document_uri; -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/single_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(?P.*)$ { 2 | proxy_pass $proxy; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_replace_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -r 'Foo: multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/not_slashed_alias_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/slashed_alias_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_w_slash_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_replace.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -r 'Foo: 2 | multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_set_header_ducument_uri.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header "X-Original-Uri" $document_uri; -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_subdomain.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/some.yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/request_uri_fp.conf: -------------------------------------------------------------------------------- 1 | location /backend/ { 2 | proxy_pass http://some$request_uri; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/wo_none_fp.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names foo.com bar.com *.none.com none.ru; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_status_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -s 404 -s '500 503' 'Foo: bar'; -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_w_slash_anchored_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru/$') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_subdomain_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/some\.yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/have_internal_fp.conf: -------------------------------------------------------------------------------- 1 | location /proxy/ { 2 | internal; 3 | proxy_pass $arg_some; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/not_host_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(.*)$ { 2 | proxy_pass http://yastatic.net/$1; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_middle.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names foo.com 2 | none bar.com; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_extract_fp.conf: -------------------------------------------------------------------------------- 1 | rewrite ^/proxy/(a|b)/(?\W*)$ http://storage/$path redirect; -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/host_w_const_start.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/backend/(?.*) { 2 | proxy_pass http://some$path; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/host_w_const_start_arg.conf: -------------------------------------------------------------------------------- 1 | location /backend/ { 2 | proxy_pass http://some${arg_la}.shit; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/scheme_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/$ { 2 | proxy_pass $http_proxy_scheme://some/file.conf; 3 | } -------------------------------------------------------------------------------- /gixy/core/utils.py: -------------------------------------------------------------------------------- 1 | def is_indexed_name(name): 2 | return isinstance(name, int) or (len(name) == 1 and '1' <= name <= '9') 3 | -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_last.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names 2 | foo.com 3 | none; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_type_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html 2 | text/plain' 'X-Foo: some'; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_cr_fp.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/test/(.*) { 2 | proxy_pass http://10.10.10.10/$1; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_lf.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/test/([^/]+)/ { 2 | proxy_pass http://10.10.10.10/$1; 3 | } 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsing>=1.5.5,<3 2 | cached-property>=1.2.0 3 | argparse>=1.4.0 4 | six>=1.1.0 5 | Jinja2>=2.8 6 | ConfigArgParse>=0.11.0 -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: Bar multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | proxy_pass http://storage/$2; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/nested.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | location /files/images { 3 | alias /home/; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/nested_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | location /files/images { 3 | } 4 | alias /home/; 5 | } 6 | -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_dot.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example\.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/request_uri_var_fp.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | set $upstream "http://some$request_uri"; 3 | proxy_pass $upstream; 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | ADD . /src 4 | 5 | WORKDIR /src 6 | 7 | RUN python3 setup.py install 8 | 9 | ENTRYPOINT ["gixy"] 10 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: Bar 3 | multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/non_block_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | server "some"; 3 | add_header X-Foo foo; 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/dont_report_not_resolved_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | proxy_pass http://storage/$some; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_prefix.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "https://example\.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_suffix.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example\.com"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/if_replaces.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | 3 | if (1) { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/not_secure_both_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Bar bar; 2 | 3 | location /new-headers { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | set $p $2; 3 | proxy_pass http://storage/$p; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | set $p $1; 3 | proxy_pass http://storage/$p; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/add_header.conf: -------------------------------------------------------------------------------- 1 | add_header Content-Security-Policy " 2 | default-src: 'none'; 3 | font-src data: https://yastatic.net;"; -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_https.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"], "https_only": true} 2 | 3 | if ($http_origin !~ '^https?:\/\/yandex\.ru\/') { 4 | 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_wo_slash.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"]} 2 | 3 | http { 4 | if ($http_origin !~ '^https?:\/\/yandex\.ru') { 5 | 6 | } 7 | } -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Gixy" 2 | published and distributed by YANDEX LLC as the owner: 3 | 4 | Andrew Krasichkov -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/location_replaces.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | 3 | location /new-headers { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/not_secure_outer_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Bar bar; 2 | 3 | location /new-headers { 4 | add_header X-Frame-Options "DENY" always; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_https_fp.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"], "https_only": true} 2 | 3 | if ($http_origin !~ '^https:\/\/yandex\.ru\/') { 4 | 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(?

\W*)$ { 2 | set $upstream "http://$1/$p?"; 3 | proxy_pass $upstream; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/vars_from_loc.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(.*)/(.*)/(.*)$ { 2 | set $scheme $1; 3 | set $host $2; 4 | set $path $3; 5 | proxy_pass $scheme://$host/$path; 6 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/metrika.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https?://([^/]+metrika.*yandex\.(ru|ua|com|com\.tr|by|kz)|([^/]+\.)?webvisor\.com)/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/step_replaces.conf: -------------------------------------------------------------------------------- 1 | http { 2 | add_header X-Frame-Options "DENY" always; 3 | server { 4 | location /new-headers { 5 | add_header X-Foo foo; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/webvisor.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["webvisor.com", "yandex.com"]} 2 | 3 | if ($http_referer !~ "^https?://([^/]+\.)?yandex\.com/|([^/]+\.)?webvisor\.com/"){ 4 | add_header X-Frame-Options SAMEORIGIN; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_multiple.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: some 3 | multiline' 4 | 'X-Bar: some 5 | multiline' 6 | 'X-Baz: some 7 | multiline'; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build publish 2 | 3 | all: build publish 4 | 5 | build: 6 | python setup.py bdist_wheel --universal sdist 7 | 8 | publish: 9 | twine upload dist/gixy-`grep -oP "(?<=version\s=\s['\"])[^'\"]*(?=['\"])" gixy/__init__.py`* 10 | 11 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/duplicate_fp.conf: -------------------------------------------------------------------------------- 1 | http { 2 | add_header X-Frame-Options "DENY" always; 3 | server { 4 | location /new-headers { 5 | add_header X-Frame-Options "DENY" always; 6 | add_header X-Foo foo; 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /gixy/core/severity.py: -------------------------------------------------------------------------------- 1 | UNSPECIFIED = 'UNSPECIFIED' 2 | LOW = 'LOW' 3 | MEDIUM = 'MEDIUM' 4 | HIGH = 'HIGH' 5 | ALL = [UNSPECIFIED, LOW, MEDIUM, HIGH] 6 | 7 | 8 | def is_acceptable(current_severity, min_severity): 9 | return ALL.index(current_severity) >= ALL.index(min_severity) 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_file = lf 5 | insert_final_newline = true 6 | 7 | [*.{py,j2}] 8 | charset = utf-8 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [Makefile] 15 | indent_style = tab 16 | 17 | [.travis.yml] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/nested_block.conf: -------------------------------------------------------------------------------- 1 | server { 2 | add_header X-Frame-Options "DENY" always; 3 | location / { 4 | location /some { 5 | add_header X-Frame-Options "DENY" always; 6 | } 7 | 8 | location /another { 9 | add_header X-Foo foo; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/with_const_scheme.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/internal-proxy/(https?)/(.*?)/(.*) { 2 | resolver 127.0.0.1; 3 | 4 | set $proxy_protocol $1; 5 | set $proxy_host $2; 6 | set $proxy_path $3; 7 | 8 | proxy_pass $proxy_protocol://$proxy_host/$proxy_path ; 9 | proxy_set_header Host $proxy_host; 10 | } -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26, py27, py34, py35, py36, py37, flake8 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | deps = 7 | -rrequirements.txt 8 | -rrequirements.dev.txt 9 | commands = nosetests -v 10 | 11 | [testenv:flake8] 12 | deps = 13 | flake8 14 | basepython = python3 15 | commands = 16 | flake8 setup.py gixy 17 | 18 | [flake8] 19 | max_line_length = 120 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | sudo: false 4 | python: 5 | - "2.7" 6 | - "3.5" 7 | - "3.6" 8 | - "3.7" 9 | - "pypy" 10 | - "pypy3" 11 | 12 | install: 13 | - pip install -r requirements.txt 14 | - pip install -r requirements.dev.txt 15 | 16 | script: 17 | - nosetests --with-coverage --cover-package gixy -v 18 | - if [[ $TRAVIS_PYTHON_VERSION != '2.6' ]]; then flake8 --max-line-length=120 setup.py gixy; fi 19 | -------------------------------------------------------------------------------- /gixy/formatters/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from gixy.formatters.base import BaseFormatter 4 | from gixy.formatters._jinja import load_template 5 | 6 | 7 | class TextFormatter(BaseFormatter): 8 | def __init__(self): 9 | super(TextFormatter, self).__init__() 10 | self.template = load_template('text.j2') 11 | 12 | def format_reports(self, reports, stats): 13 | return self.template.render(reports=reports, stats=stats) 14 | -------------------------------------------------------------------------------- /gixy/formatters/console.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from gixy.formatters.base import BaseFormatter 4 | from gixy.formatters._jinja import load_template 5 | 6 | 7 | class ConsoleFormatter(BaseFormatter): 8 | def __init__(self): 9 | super(ConsoleFormatter, self).__init__() 10 | self.template = load_template('console.j2') 11 | 12 | def format_reports(self, reports, stats): 13 | return self.template.render(reports=reports, stats=stats) 14 | -------------------------------------------------------------------------------- /gixy/formatters/_jinja.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from jinja2 import Environment, PackageLoader 3 | 4 | from gixy.utils.text import to_text 5 | 6 | 7 | def load_template(name): 8 | env = Environment(loader=PackageLoader('gixy', 'formatters/templates'), trim_blocks=True, lstrip_blocks=True) 9 | env.filters['to_text'] = to_text_filter 10 | return env.get_template(name) 11 | 12 | 13 | def to_text_filter(text): 14 | try: 15 | return text.encode('latin1').decode('utf-8') 16 | except UnicodeEncodeError: 17 | return to_text(text) 18 | -------------------------------------------------------------------------------- /gixy/core/issue.py: -------------------------------------------------------------------------------- 1 | class Issue(object): 2 | def __init__(self, plugin, summary=None, description=None, 3 | severity=None, reason=None, help_url=None, directives=None): 4 | self.plugin = plugin 5 | self.summary = summary 6 | self.description = description 7 | self.severity = severity 8 | self.reason = reason 9 | self.help_url = help_url 10 | if not directives: 11 | self.directives = [] 12 | elif not hasattr(directives, '__iter__'): 13 | self.directives = [directives] 14 | else: 15 | self.directives = directives 16 | -------------------------------------------------------------------------------- /gixy/plugins/valid_referers.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class valid_referers(Plugin): 6 | """ 7 | Insecure example: 8 | valid_referers none server_names *.webvisor.com; 9 | """ 10 | summary = 'Used "none" as valid referer.' 11 | severity = gixy.severity.HIGH 12 | description = 'Never trust undefined referer.' 13 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/validreferers.md' 14 | directives = ['valid_referers'] 15 | 16 | def audit(self, directive): 17 | if 'none' in directive.args: 18 | self.add_issue(directive=directive) 19 | -------------------------------------------------------------------------------- /gixy/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gixy.formatters.base import BaseFormatter 3 | 4 | FORMATTERS = {} 5 | 6 | 7 | def import_formatters(): 8 | files_list = os.listdir(os.path.dirname(__file__)) 9 | for formatter_file in files_list: 10 | if not formatter_file.endswith(".py") or formatter_file.startswith('_'): 11 | continue 12 | __import__('gixy.formatters.' + os.path.splitext(formatter_file)[0], None, None, ['']) 13 | 14 | 15 | def get_all(): 16 | if len(FORMATTERS): 17 | return FORMATTERS 18 | 19 | import_formatters() 20 | for klass in BaseFormatter.__subclasses__(): 21 | FORMATTERS[klass.__name__.replace('Formatter', '').lower()] = klass 22 | 23 | return FORMATTERS 24 | -------------------------------------------------------------------------------- /gixy/directives/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gixy.directives.directive import Directive 3 | 4 | DIRECTIVES = {} 5 | 6 | 7 | def import_directives(): 8 | files_list = os.listdir(os.path.dirname(__file__)) 9 | for directive_file in files_list: 10 | if not directive_file.endswith(".py") or directive_file.startswith('_'): 11 | continue 12 | __import__('gixy.directives.' + os.path.splitext(directive_file)[0], None, None, ['']) 13 | 14 | 15 | def get_all(): 16 | if len(DIRECTIVES): 17 | return DIRECTIVES 18 | 19 | import_directives() 20 | for klass in Directive.__subclasses__(): 21 | if not klass.nginx_name: 22 | continue 23 | DIRECTIVES[klass.nginx_name] = klass 24 | 25 | return DIRECTIVES 26 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | **/__pycache__/ 3 | **/*.py[cod] 4 | 5 | # C extensions 6 | ***/*.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | cover 40 | 41 | # Translations 42 | **/*.mo 43 | **/*.pot 44 | 45 | # PyBuilder 46 | target/ 47 | 48 | venv/ 49 | venv3/ 50 | .idea/ 51 | 52 | # 100% unnecessary for docker image 53 | .* 54 | *.md 55 | docs 56 | rpm 57 | Dockerfile 58 | -------------------------------------------------------------------------------- /gixy/plugins/host_spoofing.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class host_spoofing(Plugin): 6 | """ 7 | Insecure example: 8 | proxy_set_header Host $http_host 9 | """ 10 | summary = 'The proxied Host header may be spoofed.' 11 | severity = gixy.severity.MEDIUM 12 | description = 'In most cases "$host" variable are more appropriate, just use it.' 13 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/hostspoofing.md' 14 | directives = ['proxy_set_header'] 15 | 16 | def audit(self, directive): 17 | name, value = directive.args 18 | if name.lower() != 'host': 19 | # Not a "Host" header 20 | return 21 | 22 | if value == '$http_host' or value.startswith('$arg_'): 23 | self.add_issue(directive=directive) 24 | -------------------------------------------------------------------------------- /gixy/formatters/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | 5 | from gixy.formatters.base import BaseFormatter 6 | 7 | 8 | class JsonFormatter(BaseFormatter): 9 | def format_reports(self, reports, stats): 10 | result = [] 11 | for path, issues in reports.items(): 12 | for issue in issues: 13 | result.append(dict( 14 | path=path, 15 | plugin=issue['plugin'], 16 | summary=issue['summary'], 17 | severity=issue['severity'], 18 | description=issue['description'], 19 | reference=issue['help_url'], 20 | reason=issue['reason'], 21 | config=issue['config'] 22 | )) 23 | 24 | return json.dumps(result, sort_keys=True, indent=2, separators=(',', ': ')) 25 | -------------------------------------------------------------------------------- /gixy/plugins/plugin.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.core.issue import Issue 3 | 4 | 5 | class Plugin(object): 6 | summary = '' 7 | description = '' 8 | help_url = '' 9 | severity = gixy.severity.UNSPECIFIED 10 | directives = [] 11 | options = {} 12 | 13 | def __init__(self, config): 14 | self._issues = [] 15 | self.config = config 16 | 17 | def add_issue(self, directive, summary=None, severity=None, description=None, reason=None, help_url=None): 18 | self._issues.append(Issue(self, directives=directive, summary=summary, severity=severity, 19 | description=description, reason=reason, help_url=help_url)) 20 | 21 | def audit(self, directive): 22 | pass 23 | 24 | @property 25 | def issues(self): 26 | return self._issues 27 | 28 | @property 29 | def name(self): 30 | return self.__class__.__name__ 31 | -------------------------------------------------------------------------------- /gixy/core/config.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | 3 | 4 | class Config(object): 5 | def __init__(self, 6 | plugins=None, 7 | skips=None, 8 | severity=gixy.severity.UNSPECIFIED, 9 | output_format=None, 10 | output_file=None, 11 | allow_includes=True): 12 | self.severity = severity 13 | self.output_format = output_format 14 | self.output_file = output_file 15 | self.plugins = plugins 16 | self.skips = skips 17 | self.allow_includes = allow_includes 18 | self.plugins_options = {} 19 | 20 | def set_for(self, name, options): 21 | self.plugins_options[name] = options 22 | 23 | def get_for(self, name): 24 | if self.has_for(name): 25 | return self.plugins_options[name] 26 | return {} 27 | 28 | def has_for(self, name): 29 | return name in self.plugins_options 30 | -------------------------------------------------------------------------------- /docs/ru/plugins/aliastraversal.md: -------------------------------------------------------------------------------- 1 | # [alias_traversal] Path traversal при использовании alias 2 | 3 | Директива [alias](https://nginx.ru/ru/docs/http/ngx_http_core_module.html#alias) используется для замены пути указанного локейшена. 4 | К примеру, для конфигурации: 5 | ```nginx 6 | location /i/ { 7 | alias /data/w3/images/; 8 | } 9 | ``` 10 | на запрос `/i/top.gif` будет отдан файл `/data/w3/images/top.gif`. 11 | 12 | Однако, если локейшен не оканчивается разделителем директорий (`/`): 13 | ```nginx 14 | location /i { 15 | alias /data/w3/images/; 16 | } 17 | ``` 18 | то на запрос `/i../app/config.py` будет отдан файл `/data/w3/app/config.py`. 19 | 20 | Иными словами, не корректная конфигурация `alias` может позволить злоумышленнику прочесть файл за пределами целевой директории. 21 | 22 | ## Что делать? 23 | Все довольно просто: 24 | - необходимо найти все директивы `alias`; 25 | - убедится что вышестоящий префиксный локейшен оканчивается на `/`. 26 | -------------------------------------------------------------------------------- /docs/en/plugins/addheadermultiline.md: -------------------------------------------------------------------------------- 1 | # [add_header_multiline] Multiline response headers 2 | 3 | You should avoid using multiline response headers, because: 4 | * they are deprecated (see [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2.4)); 5 | * some HTTP-clients and web browser never supported them (e.g. IE/Edge/Nginx). 6 | 7 | ## How can I find it? 8 | Misconfiguration example: 9 | ```nginx 10 | # http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header 11 | add_header Content-Security-Policy " 12 | default-src: 'none'; 13 | script-src data: https://yastatic.net; 14 | style-src data: https://yastatic.net; 15 | img-src data: https://yastatic.net; 16 | font-src data: https://yastatic.net;"; 17 | 18 | # https://www.nginx.com/resources/wiki/modules/headers_more/ 19 | more_set_headers -t 'text/html text/plain' 20 | 'X-Foo: Bar 21 | multiline'; 22 | ``` 23 | 24 | ## What can I do? 25 | The only solution is to never use multiline response headers. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | !rpm/*.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | venv/ 62 | venv3/ 63 | .idea/ 64 | -------------------------------------------------------------------------------- /docs/ru/plugins/addheadermultiline.md: -------------------------------------------------------------------------------- 1 | # [add_header_multiline] Многострочные заголовоки ответа 2 | 3 | Многострочных заголовков ответа стоит избегать по нескольким причинам: 4 | * они признаны устаревшими (см. [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2.4)); 5 | * они никогда не поддерживались многими HTTP-клиентами и браузерами. Например, IE/Edge/Nginx. 6 | 7 | ## Как самостоятельно обнаружить? 8 | Пример плохой конфигурации: 9 | ```nginx 10 | # http://nginx.org/ru/docs/http/ngx_http_headers_module.html#add_header 11 | add_header Content-Security-Policy " 12 | default-src: 'none'; 13 | script-src data: https://yastatic.net; 14 | style-src data: https://yastatic.net; 15 | img-src data: https://yastatic.net; 16 | font-src data: https://yastatic.net;"; 17 | 18 | # https://www.nginx.com/resources/wiki/modules/headers_more/ 19 | more_set_headers -t 'text/html text/plain' 20 | 'X-Foo: Bar 21 | multiline'; 22 | ``` 23 | 24 | ## Что делать? 25 | Единственный выход - отказ от многострочных заголовок ответа. -------------------------------------------------------------------------------- /docs/en/plugins/aliastraversal.md: -------------------------------------------------------------------------------- 1 | # [alias_traversal] Path traversal via misconfigured alias 2 | 3 | The [alias](https://nginx.ru/en/docs/http/ngx_http_core_module.html#alias) directive is used to replace path of the specified location. 4 | For example, with the following configuration: 5 | ```nginx 6 | location /i/ { 7 | alias /data/w3/images/; 8 | } 9 | ``` 10 | on request of `/i/top.gif`, the file `/data/w3/images/top.gif` will be sent. 11 | 12 | But, if the location doesn't ends with directory separator (i.e. `/`): 13 | ```nginx 14 | location /i { 15 | alias /data/w3/images/; 16 | } 17 | ``` 18 | on request of `/i../app/config.py`, the file `/data/w3/app/config.py` will be sent. 19 | 20 | In other words, the incorrect configuration of `alias` could allow an attacker to read file stored outside the target folder. 21 | 22 | ## What can I do? 23 | It's pretty simple: 24 | - you must find all the `alias` directives; 25 | - make sure that the parent prefixed location ends with directory separator. 26 | - or if you want to map a single file make sure the location starts with a `=`, e.g `=/i.gif` instead of `/i.gif`. 27 | -------------------------------------------------------------------------------- /gixy/formatters/templates/text.j2: -------------------------------------------------------------------------------- 1 | 2 | ==================== Results =================== 3 | {% for path, issues in reports.items() %} 4 | {% if reports|length > 1 %} 5 | File path: {{ path }} 6 | {% endif %} 7 | {% if not issues %} 8 | No issues found. 9 | 10 | {% else %} 11 | 12 | {% for issue in issues|sort(attribute='severity') %} 13 | >> Problem: [{{ issue.plugin }}] {{ issue.summary }} 14 | Severity: {{ issue.severity }} 15 | {% if issue.description %} 16 | Description: {{ issue.description }} 17 | {% endif %} 18 | {% if issue.help_url %} 19 | Additional info: {{ issue.help_url }} 20 | {% endif %} 21 | {% if issue.reason %} 22 | Reason: {{ issue.reason }} 23 | {% endif %} 24 | Pseudo config: 25 | {{ issue.config | to_text }} 26 | 27 | {% if not loop.last %} 28 | ------------------------------------------------ 29 | 30 | {% endif %} 31 | {% endfor %} 32 | {% endif %} 33 | {% if not loop.last %} 34 | --------8<--------8<--------8<--------8<-------- 35 | {% endif %} 36 | {% endfor %} 37 | {% if stats %} 38 | ==================== Summary =================== 39 | Total issues: 40 | Unspecified: {{ stats.UNSPECIFIED }} 41 | Low: {{ stats.LOW }} 42 | Medium: {{ stats.MEDIUM }} 43 | High: {{ stats.HIGH }} 44 | {% endif %} 45 | -------------------------------------------------------------------------------- /gixy/plugins/alias_traversal.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class alias_traversal(Plugin): 6 | """ 7 | Insecure example: 8 | location /files { 9 | alias /home/; 10 | } 11 | """ 12 | summary = 'Path traversal via misconfigured alias.' 13 | severity = gixy.severity.HIGH 14 | description = 'Using alias in a prefixed location that doesn\'t ends with directory separator could lead to path ' \ 15 | 'traversal vulnerability. ' 16 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md' 17 | directives = ['alias'] 18 | 19 | def audit(self, directive): 20 | for location in directive.parents: 21 | if location.name != 'location': 22 | continue 23 | 24 | if not location.modifier or location.modifier == '^~': 25 | # We need non-strict prefixed locations 26 | if not location.path.endswith('/'): 27 | self.add_issue( 28 | severity=gixy.severity.HIGH if directive.path.endswith('/') else gixy.severity.MEDIUM, 29 | directive=[directive, location] 30 | ) 31 | break 32 | -------------------------------------------------------------------------------- /docs/en/plugins/validreferers.md: -------------------------------------------------------------------------------- 1 | # [valid_referers] none in valid_referers 2 | Module [ngx_http_referer_module](http://nginx.org/en/docs/http/ngx_http_referer_module.html) allows to block the access to service for requests with wrong `Referer` value. 3 | It's often used for setting `X-Frame-Options` header (ClickJacking protection), but there may be other cases. 4 | 5 | Typical problems with this module's config: 6 | * use of `server_names` with bad server name (`server_name` directive); 7 | * too broad and/or bad regexes; 8 | * use of `none`. 9 | 10 | > Notice: at the moment, Gixy can only detect the use of `none` as a valid referer. 11 | 12 | ## Why none is bad? 13 | According to [docs](http://nginx.org/ru/docs/http/ngx_http_referer_module.html#valid_referers): 14 | > `none` - the “Referer” field is missing in the request header; 15 | 16 | Still, it's important to remember that any resource can make user's browser to make a request without a `Referer` request header. 17 | E.g.: 18 | - in case of redirect from HTTPS to HTTP; 19 | - by setting up the [Referrer Policy](https://www.w3.org/TR/referrer-policy/); 20 | - a request with opaque origin, `data:` scheme, for example. 21 | 22 | So, by using `none` as a valid referer, you nullify any attemps in refferer validation. -------------------------------------------------------------------------------- /tests/asserts.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_true, assert_false 2 | 3 | 4 | ''' 5 | Various nose.tools helpers that doesn't exists in Python 2.6 Unittest :( 6 | Must be removed with drop Python 2.6 support 7 | ''' 8 | 9 | 10 | def assert_is_instance(obj, cls, msg=None): 11 | """Same as assert_true(isinstance(obj, cls)), with a nicer 12 | default message.""" 13 | if not msg: 14 | msg = '{orig} is not an instance of {test}'.format(orig=type(obj), test=cls) 15 | assert_true(isinstance(obj, cls), msg=msg) 16 | 17 | 18 | def assert_is_none(obj, msg=None): 19 | """Same as assert_true(obj is None), with a nicer default message.""" 20 | if not msg: 21 | msg = '{orig!r} is not None'.format(orig=obj) 22 | assert_true(obj is None, msg=msg) 23 | 24 | 25 | def assert_is_not_none(obj, msg=None): 26 | """Same as assert_false(obj is None), with a nicer default message.""" 27 | if not msg: 28 | msg = '{orig!r} is None'.format(orig=obj) 29 | assert_false(obj is None, msg=msg) 30 | 31 | 32 | def assert_in(member, container, msg=None): 33 | """Just like assert_true(a in b), but with a nicer default message.""" 34 | if not msg: 35 | msg = '{member!r} not found in {container!r}'.format(member=member, container=container) 36 | assert_true(member in container, msg=msg) 37 | -------------------------------------------------------------------------------- /docs/ru/plugins/validreferers.md: -------------------------------------------------------------------------------- 1 | # [valid_referers] none in valid_referers 2 | 3 | Модуль [ngx_http_referer_module](http://nginx.org/ru/docs/http/ngx_http_referer_module.html) позволяет блокировать доступ к сервису для запросов с неверными значениями заголовка запроса `Referer`. 4 | Зачастую используется для условного выставления заголовка `X-Frame-Options` (защита от ClickJacking), но могут быть и иные случаи. 5 | 6 | Типичные проблемы при конфигурировании этого модуля: 7 | * использование `server_names` при не корректном имени сервера (директива `server_name`); 8 | * слишком общие и/или не корректные регулярные выражения; 9 | * использование `none`. 10 | 11 | > На текущий момент, Gixy умеет определять только использование `none` в качестве валидного реферера. 12 | 13 | ## Чем плох none? 14 | Согласно [документации](http://nginx.org/ru/docs/http/ngx_http_referer_module.html#valid_referers): 15 | > `none` - поле “Referer” в заголовке запроса отсутствует; 16 | 17 | Однако, важно помнить, что любой ресурс может заставить браузер пользователя выполнить запрос без заголовка запроса `Referer`, к примеру: 18 | - в случае редиректа с HTTPS на HTTP; 19 | - указав соответствующую [Referrer Policy](https://www.w3.org/TR/referrer-policy/); 20 | - обращение с opaque origin, например, используя схему `data:`. 21 | 22 | Таким образом, используя `none` в качестве валидного реферера вы сводите на нет любые попытки валидации реферера. -------------------------------------------------------------------------------- /docs/en/plugins/hostspoofing.md: -------------------------------------------------------------------------------- 1 | # [host_spoofing] Request's Host header forgery 2 | 3 | Often, an application located behind Nginx needs a correct `Host` header for URL generation (redirects, resources, links in emails etc.). 4 | Spoofing of this header, may leads to a variety of problems, from phishing to SSRF. 5 | 6 | > Notice: your application may also use the `X-Forwarded-Host` request header for this functionality. 7 | > In this case you have to ensure the header is set correctly; 8 | 9 | ## How can I find it? 10 | Most of the time it's a result of using `$http_host` variable instead of `$host`. 11 | 12 | And they are quite different: 13 | * `$host` - host in this order of precedence: host name from the request line, or host name from the “Host” request header field, or the server name matching a request; 14 | * `$http_host` - "Host" request header. 15 | 16 | Config sample: 17 | ```nginx 18 | location @app { 19 | proxy_set_header Host $http_host; 20 | # Other proxy params 21 | proxy_pass http://backend; 22 | } 23 | ``` 24 | 25 | ## What can I do? 26 | Luckily, all is quite obvious: 27 | * list all the correct server names in `server name` directive; 28 | * always use `$host` instead of `$http_host`. 29 | 30 | ## Additional info 31 | * [Host of Troubles Vulnerabilities](https://hostoftroubles.com/) 32 | * [Practical HTTP Host header attacks](http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html) 33 | -------------------------------------------------------------------------------- /gixy/formatters/templates/console.j2: -------------------------------------------------------------------------------- 1 | {% set colors = {'DEF': '\033[0m', 'TITLE': '\033[95m', 'UNSPECIFIED': '\033[0m', 'LOW': '\033[94m', 'MEDIUM': '\033[93m', 'HIGH': '\033[91m'} %} 2 | 3 | {{ colors.TITLE }}==================== Results ==================={{ colors.DEF }} 4 | {% for path, issues in reports.items() %} 5 | {% if reports|length > 1 %} 6 | File path: {{ path }} 7 | {% endif %} 8 | {% if not issues %} 9 | No issues found. 10 | 11 | {% else %} 12 | 13 | {% for issue in issues|sort(attribute='severity') %} 14 | {{ colors[issue.severity] }}>> Problem: [{{ issue.plugin }}] {{ issue.summary }}{{ colors.DEF }} 15 | {% if issue.description %} 16 | Description: {{ issue.description }} 17 | {% endif %} 18 | {% if issue.help_url %} 19 | Additional info: {{ issue.help_url }} 20 | {% endif %} 21 | {% if issue.reason %} 22 | Reason: {{ issue.reason }} 23 | {% endif %} 24 | Pseudo config: 25 | {{ issue.config | to_text }} 26 | 27 | {% if not loop.last %} 28 | ------------------------------------------------ 29 | 30 | {% endif %} 31 | {% endfor %} 32 | {% endif %} 33 | {% if not loop.last %} 34 | --------8<--------8<--------8<--------8<-------- 35 | {% endif %} 36 | {% endfor %} 37 | {% if stats %} 38 | {{ colors.TITLE }}==================== Summary ==================={{ colors.DEF }} 39 | Total issues: 40 | Unspecified: {{ stats.UNSPECIFIED }} 41 | Low: {{ stats.LOW }} 42 | Medium: {{ stats.MEDIUM }} 43 | High: {{ stats.HIGH }} 44 | {% endif %} 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | 4 | with open('gixy/__init__.py', 'r') as fd: 5 | version = re.search(r'^version\s*=\s*[\'"]([^\'"]*)[\'"]', 6 | fd.read(), re.MULTILINE).group(1) 7 | 8 | if not version: 9 | raise RuntimeError('Cannot find version information') 10 | 11 | setup( 12 | name='gixy', 13 | version=version, 14 | description='Nginx configuration [sec]analyzer', 15 | keywords='nginx security lint static-analysis', 16 | author='Yandex IS Team', 17 | author_email='buglloc@yandex.ru', 18 | url='https://github.com/yandex/gixy', 19 | install_requires=[ 20 | 'pyparsing>=1.5.5,<3', 21 | 'cached-property>=1.2.0', 22 | 'argparse>=1.4.0;python_version<"3.2"', 23 | 'six>=1.1.0', 24 | 'Jinja2>=2.8', 25 | 'ConfigArgParse>=0.11.0' 26 | ], 27 | entry_points={ 28 | 'console_scripts': ['gixy=gixy.cli.main:main'], 29 | }, 30 | test_suite='nose.collector', 31 | packages=find_packages(exclude=['tests', 'tests.*']), 32 | classifiers=[ 33 | 'Development Status :: 3 - Alpha', 34 | 'Environment :: Console', 35 | 'Intended Audience :: System Administrators', 36 | 'Intended Audience :: Developers', 37 | 'Topic :: Security', 38 | 'Topic :: Software Development :: Quality Assurance', 39 | 'Topic :: Software Development :: Testing' 40 | ], 41 | include_package_data=True 42 | ) 43 | -------------------------------------------------------------------------------- /docs/en/plugins/origins.md: -------------------------------------------------------------------------------- 1 | # [origins] Problems with referrer/origin validation 2 | 3 | It's not unusual to use regex for `Referer` or `Origin` headers validation. 4 | Often it is needed for setting the `X-Frame-Options` header (ClickJacking protection) or Cross-Origin Resource Sharing. 5 | 6 | The most common errors with this configuration are: 7 | - regex errors; 8 | - allow 3rd-party origins. 9 | 10 | > Notice: by default Gixy doesn't check regexes for 3rd-party origins matching. 11 | > You can pass a list of trusted domains by using the option `--origins-domains example.com,foo.bar` 12 | 13 | ## How can I find it? 14 | "Eazy"-breezy: 15 | - you have to find all the `if` directives that are in charge of `$http_origin` or `$http_referer` check; 16 | - make sure your regexes are a-ok. 17 | 18 | Misconfiguration example: 19 | ```nginx 20 | if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) { 21 | add_header 'Access-Control-Allow-Origin' "$http_origin"; 22 | add_header 'Access-Control-Allow-Credentials' 'true'; 23 | } 24 | ``` 25 | 26 | TODO(buglloc): cover typical regex-writing problems 27 | TODO(buglloc): Regex Ninja? 28 | 29 | ## What can I do? 30 | 31 | - fix your regex or toss it away :) 32 | - if you use regex validation for `Referer` request header, then, possibly (not 100%), you could use [ngx_http_referer_module](http://nginx.org/en/docs/http/ngx_http_referer_module.htmll); 33 | - sometimes it is much better to use the `map` directive without any regex at all. 34 | -------------------------------------------------------------------------------- /docs/ru/plugins/hostspoofing.md: -------------------------------------------------------------------------------- 1 | # [host_spoofing] Подделка заголовка запроса Host 2 | 3 | Зачастую, приложению, стоящему за Nginx, необходимо передать корректный заголовок `Host` для корректной генерации различных URL-адресов (редиректы, ресурсы, ссылки в письмах и т.д.). 4 | Возможность его подмены злоумышленником может повлечь множестве проблем от фишинговых атак до SSRF, поэтому следует избегать таких ситуаций. 5 | 6 | > Возможно, ваше приложение так же ориентируется на заголовок запроса `X-Forwarded-Host`. 7 | > В этом случае вам необходимо самостоятельно позаботится о его корректной установке при проксировании. 8 | 9 | ## Как самостоятельно обнаружить? 10 | Чаще всего эта проблема возникает в результате использования переменной `$http_host` вместо `$host`. 11 | 12 | Несмотря на их схожесть, они сильно отличаются: 13 | * `$host` - хост в порядке приоритета: имя хоста из строки запроса, или имя хоста из заголовка `Host` заголовка запроса, или имя сервера, соответствующего запросу; 14 | * `$http_host` - заголовок запроса "Host". 15 | 16 | Пример такой конфигурации: 17 | ```nginx 18 | location @app { 19 | proxy_set_header Host $http_host; 20 | # Other proxy params 21 | proxy_pass http://backend; 22 | } 23 | ``` 24 | 25 | ## Что делать? 26 | К счастью, все довольно очевидно: 27 | * перечислить корректные имена сервера в директиве `server_name`; 28 | * всегда использовать переменную `$host`, вместо `$http_host`. 29 | 30 | ## Дополнительная информация 31 | * [Host of Troubles Vulnerabilities](https://hostoftroubles.com/) 32 | * [Practical HTTP Host header attacks](http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html) 33 | -------------------------------------------------------------------------------- /gixy/plugins/add_header_multiline.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class add_header_multiline(Plugin): 6 | """ 7 | Insecure example: 8 | add_header Content-Security-Policy " 9 | default-src: 'none'; 10 | img-src data: https://mc.yandex.ru https://yastatic.net *.yandex.net https://mc.yandex.${tld} https://mc.yandex.ru; 11 | font-src data: https://yastatic.net;"; 12 | """ 13 | summary = 'Found a multi-line header.' 14 | severity = gixy.severity.LOW 15 | description = ('Multi-line headers are deprecated (see RFC 7230). ' 16 | 'Some clients never supports them (e.g. IE/Edge).') 17 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/addheadermultiline.md' 18 | directives = ['add_header', 'more_set_headers'] 19 | 20 | def audit(self, directive): 21 | header_values = get_header_values(directive) 22 | for value in header_values: 23 | if '\n\x20' in value or '\n\t' in value: 24 | self.add_issue(directive=directive) 25 | break 26 | 27 | 28 | def get_header_values(directive): 29 | if directive.name == 'add_header': 30 | return [directive.args[1]] 31 | 32 | # See headers more documentation: https://github.com/openresty/headers-more-nginx-module#description 33 | result = [] 34 | skip_next = False 35 | for arg in directive.args: 36 | if arg in ['-s', '-t']: 37 | # Skip next value, because it's not a header 38 | skip_next = True 39 | elif arg.startswith('-'): 40 | # Skip any options 41 | pass 42 | elif skip_next: 43 | skip_next = False 44 | elif not skip_next: 45 | result.append(arg) 46 | return result 47 | -------------------------------------------------------------------------------- /docs/ru/plugins/origins.md: -------------------------------------------------------------------------------- 1 | # [origins] Проблемы валидации referrer/origin 2 | 3 | Нередко валидация заголовка запроса `Referer` или `Origin` делается при помощи регулярного выражения. 4 | Зачастую, это необходимо для условного выставления заголовка `X-Frame-Options` (защита от ClickJacking) или реализации Cross-Origin Resource Sharing. 5 | 6 | Наиболее распространенно два класса ошибок конфигурации, которые приводят к этой проблеме: 7 | - ошибки в составлении регулярного выражения; 8 | - разрешение не доверенных third-party доменов. 9 | 10 | > По умолчанию Gixy не валидирует регулярные выражение на предмет матчинга third-party доменов, т.к. не знает кому можно верить. 11 | Передать список доверенных доменом можно при помощи опции `--origins-domains example.com,foo.bar` 12 | 13 | ## Как самостоятельно обнаружить? 14 | Все довольно "просто": 15 | - необходимо найти все директивы `if`, которые делают проверку переменной `$http_origin` или `$http_referer`; 16 | - убедится что в регулярном выражении нет проблем. 17 | 18 | Пример плохой конфигурации: 19 | ```nginx 20 | if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) { 21 | add_header 'Access-Control-Allow-Origin' "$http_origin"; 22 | add_header 'Access-Control-Allow-Credentials' 'true'; 23 | } 24 | ``` 25 | 26 | TODO(buglloc): описать типичные проблемы при составлении регулярных выражений 27 | TODO(buglloc): Regex Ninja? 28 | 29 | ## Что делать? 30 | - исправить регулярное выражение или отказаться от него вовсе :) 31 | - если вы проверяете заголовок запроса `Referer` то, возможно (имеются противопоказания), лучшим решением было бы воспользоваться модулем [ngx_http_referer_module](http://nginx.org/ru/docs/http/ngx_http_referer_module.html); 32 | - если вы проверяете заголовов запроса `Origin` то, зачастую, лучше использовать `map` и отказаться от регулярных выражений. 33 | -------------------------------------------------------------------------------- /gixy/plugins/http_splitting.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | from gixy.core.variable import compile_script 4 | 5 | 6 | class http_splitting(Plugin): 7 | r""" 8 | Insecure examples: 9 | rewrite ^ http://$host$uri; 10 | return 301 http://$host$uri; 11 | proxy_set_header "X-Original-Uri" $uri; 12 | proxy_pass http://upstream$document_uri; 13 | 14 | location ~ /proxy/(a|b)/(\W*)$ { 15 | set $path $2; 16 | proxy_pass http://storage/$path; 17 | } 18 | """ 19 | 20 | summary = 'Possible HTTP-Splitting vulnerability.' 21 | severity = gixy.severity.HIGH 22 | description = 'Using variables that can contain "\\n" or "\\r" may lead to http injection.' 23 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/httpsplitting.md' 24 | directives = ['rewrite', 'return', 'add_header', 'proxy_set_header', 'proxy_pass'] 25 | 26 | def audit(self, directive): 27 | value = _get_value(directive) 28 | if not value: 29 | return 30 | 31 | server_side = directive.name.startswith('proxy_') 32 | for var in compile_script(value): 33 | char = '' 34 | if var.can_contain('\n'): 35 | char = '\\n' 36 | elif not server_side and var.can_contain('\r'): 37 | char = '\\r' 38 | else: 39 | continue 40 | reason = 'At least variable "${var}" can contain "{char}"'.format(var=var.name, char=char) 41 | self.add_issue(directive=[directive] + var.providers, reason=reason) 42 | 43 | 44 | def _get_value(directive): 45 | if directive.name == 'proxy_pass' and len(directive.args) >= 1: 46 | return directive.args[0] 47 | elif len(directive.args) >= 2: 48 | return directive.args[1] 49 | return None 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Notice to external contributors 2 | 3 | 4 | 5 | ## General info 6 | 7 | Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the “**CLA**”). The current version of the CLA you may find here: 8 | 1) https://yandex.ru/legal/cla/?lang=en (in English) and 9 | 2) https://yandex.ru/legal/cla/?lang=ru (in Russian). 10 | 11 | By adopting the CLA, you state the following: 12 | 13 | * You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, 14 | * You has read the terms and conditions of the CLA and agree with them in full, 15 | * You are legally able to provide and license your contributions as stated, 16 | * We may use your contributions for our open source projects and for any other our project too, 17 | * We rely on your assurances concerning the rights of third parties in relation to your contributes. 18 | 19 | If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you has already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. 20 | 21 | ## Provide contributions 22 | 23 | If you have already adopted terms and conditions of the CLA, you are able to provide your contributes. When you submit your pull request, please add the following information into it: 24 | 25 | ` 26 | I hereby agree to the terms of the CLA available at: [link]). 27 | ` 28 | 29 | Replace the bracketed text as follows: 30 | * [link] is the link at the current version of the CLA (you may add here a link https://yandex.ru/legal/cla/?lang=en (in English) or a link https://yandex.ru/legal/cla/?lang=ru (in Russian). 31 | 32 | It is enough to provide us such notification at once. 33 | 34 | ## Other questions 35 | 36 | If you have any questions, please mail us at opensource@yandex-team.ru. 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from logging.handlers import BufferingHandler 2 | 3 | 4 | class LogHandler(BufferingHandler): 5 | def __init__(self, matcher): 6 | # BufferingHandler takes a "capacity" argument 7 | # so as to know when to flush. As we're overriding 8 | # shouldFlush anyway, we can set a capacity of zero. 9 | # You can call flush() manually to clear out the 10 | # buffer. 11 | super(LogHandler, self).__init__(0) 12 | self.matcher = matcher 13 | 14 | def shouldFlush(self, **kwargs): 15 | return False 16 | 17 | def emit(self, record): 18 | self.buffer.append(record.__dict__) 19 | 20 | def matches(self, **kwargs): 21 | """ 22 | Look for a saved dict whose keys/values match the supplied arguments. 23 | """ 24 | result = False 25 | for d in self.buffer: 26 | if self.matcher.matches(d, **kwargs): 27 | result = True 28 | break 29 | return result 30 | 31 | 32 | class Matcher(object): 33 | 34 | _partial_matches = ('msg', 'message') 35 | 36 | def matches(self, d, **kwargs): 37 | """ 38 | Try to match a single dict with the supplied arguments. 39 | 40 | Keys whose values are strings and which are in self._partial_matches 41 | will be checked for partial (i.e. substring) matches. You can extend 42 | this scheme to (for example) do regular expression matching, etc. 43 | """ 44 | result = True 45 | for k in kwargs: 46 | v = kwargs[k] 47 | dv = d.get(k) 48 | if not self.match_value(k, dv, v): 49 | result = False 50 | break 51 | return result 52 | 53 | def match_value(self, k, dv, v): 54 | """ 55 | Try to match a single stored value (dv) with a supplied value (v). 56 | """ 57 | if type(v) != type(dv): 58 | result = False 59 | elif type(dv) is not str or k not in self._partial_matches: 60 | result = (v == dv) 61 | else: 62 | result = dv.find(v) >= 0 63 | return result 64 | -------------------------------------------------------------------------------- /gixy/plugins/ssrf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | from gixy.core.context import get_context 6 | from gixy.core.variable import compile_script 7 | 8 | 9 | class ssrf(Plugin): 10 | """ 11 | Insecure examples: 12 | location ~ /proxy/(.*)/(.*)/(.*)$ { 13 | set $scheme $1; 14 | set $host $2; 15 | set $path $3; 16 | proxy_pass $scheme://$host/$path; 17 | } 18 | 19 | location /proxy/ { 20 | proxy_pass $arg_some; 21 | } 22 | """ 23 | 24 | summary = 'Possible SSRF (Server Side Request Forgery) vulnerability.' 25 | severity = gixy.severity.HIGH 26 | description = 'The configuration may allow attacker to create a arbitrary requests from the vulnerable server.' 27 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/ssrf.md' 28 | directives = ['proxy_pass'] 29 | 30 | def __init__(self, config): 31 | super(ssrf, self).__init__(config) 32 | self.parse_uri_re = re.compile(r'(?P[^?#/)]+://)?(?P[^?#/)]+)') 33 | 34 | def audit(self, directive): 35 | value = directive.args[0] 36 | if not value: 37 | return 38 | 39 | context = get_context() 40 | if context.block.name == 'location' and context.block.is_internal: 41 | # Exclude internal locations 42 | return 43 | 44 | parsed = self.parse_uri_re.match(value) 45 | if not parsed: 46 | return 47 | 48 | res = self._check_script(parsed.group('scheme'), directive) 49 | if not res: 50 | self._check_script(parsed.group('host'), directive) 51 | 52 | def _check_script(self, script, directive): 53 | for var in compile_script(script): 54 | if var.must_contain('/'): 55 | # Skip variable checks 56 | return False 57 | if var.can_contain('.'): 58 | # Yay! Our variable can contain any symbols! 59 | reason = 'At least variable "${var}" can contain untrusted user input'.format(var=var.name) 60 | self.add_issue(directive=[directive] + var.providers, reason=reason) 61 | return True 62 | return False 63 | -------------------------------------------------------------------------------- /gixy/utils/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from six import PY3, text_type, binary_type 3 | 4 | 5 | def to_bytes(obj, encoding='latin1', errors='strict', nonstring='replace'): 6 | if isinstance(obj, binary_type): 7 | return obj 8 | 9 | if isinstance(obj, text_type): 10 | try: 11 | # Try this first as it's the fastest 12 | return obj.encode(encoding, errors) 13 | except UnicodeEncodeError: 14 | return b'failed_to_encode' 15 | 16 | if nonstring == 'simplerepr': 17 | try: 18 | 19 | value = str(obj) 20 | except UnicodeError: 21 | try: 22 | value = repr(obj) 23 | except UnicodeError: 24 | # Giving up 25 | return b'failed_to_encode' 26 | elif nonstring == 'passthru': 27 | return obj 28 | elif nonstring == 'replace': 29 | return b'failed_to_encode' 30 | elif nonstring == 'strict': 31 | raise TypeError('obj must be a string type') 32 | else: 33 | raise TypeError('Invalid value %s for to_bytes\' nonstring parameter' % nonstring) 34 | 35 | return to_bytes(value, encoding, errors) 36 | 37 | 38 | def to_text(obj, encoding='latin1', errors='strict', nonstring='replace'): 39 | if isinstance(obj, text_type): 40 | return obj 41 | 42 | if isinstance(obj, binary_type): 43 | try: 44 | return obj.decode(encoding, errors) 45 | except UnicodeEncodeError: 46 | return u'failed_to_encode' 47 | 48 | if nonstring == 'simplerepr': 49 | try: 50 | value = str(obj) 51 | except UnicodeError: 52 | try: 53 | value = repr(obj) 54 | except UnicodeError: 55 | # Giving up 56 | return u'failed_to_encode' 57 | elif nonstring == 'passthru': 58 | return obj 59 | elif nonstring == 'replace': 60 | return u'failed_to_encode' 61 | elif nonstring == 'strict': 62 | raise TypeError('obj must be a string type') 63 | else: 64 | raise TypeError('Invalid value %s for to_text\'s nonstring parameter' % nonstring) 65 | 66 | return to_text(value, encoding, errors) 67 | 68 | 69 | if PY3: 70 | to_native = to_text 71 | else: 72 | to_native = to_bytes 73 | -------------------------------------------------------------------------------- /docs/en/plugins/httpsplitting.md: -------------------------------------------------------------------------------- 1 | # [http_splitting] HTTP Splitting 2 | 3 | HTTP Splitting - attack that use improper input validation. It usually targets web application located behind Nginx (HTTP Request Splitting) or its users (HTTP Response Splitting). 4 | 5 | Vulnerability is created when an attacker can insert newline character `\n` or `\r` into request or into response, created by Nginx. 6 | 7 | ## How can I find it? 8 | You should always pay attention to: 9 | - variables that are used in directives, responsible for the request creation (for they may contain CRLF), e.g. `rewrite`, `return`, `add_header`, `proxy_set_header` or `proxy_pass`; 10 | - `$uri` and `$document_uri` variables, and in which directives they are used, because these variables contain decoded URL-encoded value; 11 | - variables, that are selected from an exclusive range, e.g. `(?P[^.]+)`. 12 | 13 | 14 | An example of configuration that contains variable, selected from an exclusive range: 15 | ```nginx 16 | server { 17 | listen 80 default; 18 | 19 | location ~ /v1/((?[^.]*)\.json)?$ { 20 | add_header X-Action $action; 21 | return 200 "OK"; 22 | } 23 | } 24 | ``` 25 | 26 | Exploitation: 27 | ```http 28 | GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0 29 | Host: localhost 30 | 31 | HTTP/1.1 200 OK 32 | Server: nginx/1.11.10 33 | Date: Mon, 13 Mar 2017 21:21:29 GMT 34 | Content-Type: application/octet-stream 35 | Content-Length: 2 36 | Connection: close 37 | X-Action: see below 38 | x-crlf-header:injected 39 | 40 | OK 41 | ``` 42 | 43 | As you can see, an attacker could add `x-crlf-header: injected` response header. This was possible because: 44 | - `add_header` doesn't encode or validate input value on suggestion that author knows about the consequences; 45 | - the path value is normalize before location processing; 46 | - `$action` value was given from a regexp with an exclusive range: `[^.]*`; 47 | - as the result, `$action` value is equal to `see below\r\nx-crlf-header:injected` and on its use the response header was added. 48 | 49 | ## What can I do? 50 | - try to use safe variables, e.g. `$request_uri` instead of `$uri`; 51 | - forbid the use of the new line symbol in the exclusive range by using `/some/(?[^/\s]+)` instead of `/some/(?[^/]+` 52 | - it could be a good idea to validate `$uri` (only if you're sure you know what are you getting into). 53 | -------------------------------------------------------------------------------- /docs/en/plugins/addheaderredefinition.md: -------------------------------------------------------------------------------- 1 | # [add_header_redefinition] Redefining of response headers by "add_header" directive 2 | 3 | Unfortunately, many people don't know how the inheritance of directives works. Most often this leads to misuse of the `add_header` directive while trying to add a new response header on the nested level. 4 | This feature is mentioned in Nginx [docs](http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header): 5 | > There could be several `add_header` directives. These directives are inherited from the previous level if and only if there are no `add_header` directives defined on the current level. 6 | 7 | The logic is quite simple: if you set headers at one level (for example, in `server` section) and then at a lower level (let's say `location`) you set some other headers, then the first headers will discarded. 8 | 9 | It's easy to check: 10 | - Configuration: 11 | ```nginx 12 | server { 13 | listen 80; 14 | add_header X-Frame-Options "DENY" always; 15 | location / { 16 | return 200 "index"; 17 | } 18 | 19 | location /new-headers { 20 | # Add special cache control 21 | add_header Cache-Control "no-cache, no-store, max-age=0, must-revalidate" always; 22 | add_header Pragma "no-cache" always; 23 | 24 | return 200 "new-headers"; 25 | } 26 | } 27 | ``` 28 | - Request to location `/` (`X-Frame-Options` header is in server response): 29 | ```http 30 | GET / HTTP/1.0 31 | 32 | HTTP/1.1 200 OK 33 | Server: nginx/1.10.2 34 | Date: Mon, 09 Jan 2017 19:28:33 GMT 35 | Content-Type: application/octet-stream 36 | Content-Length: 5 37 | Connection: close 38 | X-Frame-Options: DENY 39 | 40 | index 41 | ``` 42 | - Request to location `/new-headers` (headers `Cache-Control` and `Pragma` are present, but there's no `X-Frame-Options`): 43 | ```http 44 | GET /new-headers HTTP/1.0 45 | 46 | 47 | HTTP/1.1 200 OK 48 | Server: nginx/1.10.2 49 | Date: Mon, 09 Jan 2017 19:29:46 GMT 50 | Content-Type: application/octet-stream 51 | Content-Length: 11 52 | Connection: close 53 | Cache-Control: no-cache, no-store, max-age=0, must-revalidate 54 | Pragma: no-cache 55 | 56 | new-headers 57 | ``` 58 | 59 | ## What can I do? 60 | There are several ways to solve this problem: 61 | - duplicate important headers; 62 | - set all headers at one level (`server` section is a good choice) 63 | - use [ngx_headers_more](https://www.nginx.com/resources/wiki/modules/headers_more/) module. 64 | -------------------------------------------------------------------------------- /gixy/core/context.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import copy 3 | 4 | from gixy.core.utils import is_indexed_name 5 | 6 | LOG = logging.getLogger(__name__) 7 | 8 | CONTEXTS = [] 9 | 10 | 11 | def get_context(): 12 | return CONTEXTS[-1] 13 | 14 | 15 | def purge_context(): 16 | del CONTEXTS[:] 17 | 18 | 19 | def push_context(block): 20 | if len(CONTEXTS): 21 | context = copy.deepcopy(get_context()) 22 | else: 23 | context = Context() 24 | context.set_block(block) 25 | CONTEXTS.append(context) 26 | return context 27 | 28 | 29 | def pop_context(): 30 | return CONTEXTS.pop() 31 | 32 | 33 | class Context(object): 34 | def __init__(self): 35 | self.block = None 36 | self.variables = { 37 | 'index': {}, 38 | 'name': {} 39 | } 40 | 41 | def set_block(self, directive): 42 | self.block = directive 43 | return self 44 | 45 | def clear_index_vars(self): 46 | self.variables['index'] = {} 47 | return self 48 | 49 | def add_var(self, name, var): 50 | if is_indexed_name(name): 51 | var_type = 'index' 52 | name = int(name) 53 | else: 54 | var_type = 'name' 55 | 56 | self.variables[var_type][name] = var 57 | return self 58 | 59 | def get_var(self, name): 60 | if is_indexed_name(name): 61 | var_type = 'index' 62 | name = int(name) 63 | else: 64 | var_type = 'name' 65 | 66 | result = None 67 | try: 68 | result = self.variables[var_type][name] 69 | except KeyError: 70 | if var_type == 'name': 71 | # Only named variables can be builtins 72 | import gixy.core.builtin_variables as builtins 73 | 74 | if builtins.is_builtin(name): 75 | result = builtins.builtin_var(name) 76 | 77 | if not result: 78 | LOG.info("Can't find variable '{0}'".format(name)) 79 | return result 80 | 81 | def __deepcopy__(self, memo): 82 | cls = self.__class__ 83 | result = cls.__new__(cls) 84 | memo[id(self)] = result 85 | result.block = copy.copy(self.block) 86 | result.variables = { 87 | 'index': copy.copy(self.variables['index']), 88 | 'name': copy.copy(self.variables['name']) 89 | } 90 | return result 91 | -------------------------------------------------------------------------------- /docs/ru/plugins/addheaderredefinition.md: -------------------------------------------------------------------------------- 1 | # [add_header_redefinition] Переопределение "вышестоящих" заголовков ответа директивой "add_header" 2 | 3 | К сожалению, многие считают что с помощью директивы `add_header` можно произвольно доопределять заголовки ответа. 4 | Это не так, о чем сказано в [документации](http://nginx.org/ru/docs/http/ngx_http_headers_module.html#add_header) к Nginx: 5 | > Директив `add_header` может быть несколько. Директивы наследуются с предыдущего уровня при условии, что на данном уровне не описаны свои директивы `add_header`. 6 | 7 | К слову, так работает наследование большинства директив в nginx'е. Если вы задаёте что-то на каком-то уровне конфигурации (например, в локейшене), то наследования с предыдущих уровней (например, с http секции) - не будет. 8 | 9 | В этом довольно легко убедится: 10 | - Конфигурация: 11 | ```nginx 12 | server { 13 | listen 80; 14 | add_header X-Frame-Options "DENY" always; 15 | location / { 16 | return 200 "index"; 17 | } 18 | 19 | location /new-headers { 20 | # Add special cache control 21 | add_header Cache-Control "no-cache, no-store, max-age=0, must-revalidate" always; 22 | add_header Pragma "no-cache" always; 23 | 24 | return 200 "new-headers"; 25 | } 26 | } 27 | ``` 28 | - Запрос к локейшену `/` (заголовок `X-Frame-Options` есть в ответе сервера): 29 | ```http 30 | GET / HTTP/1.0 31 | 32 | HTTP/1.1 200 OK 33 | Server: nginx/1.10.2 34 | Date: Mon, 09 Jan 2017 19:28:33 GMT 35 | Content-Type: application/octet-stream 36 | Content-Length: 5 37 | Connection: close 38 | X-Frame-Options: DENY 39 | 40 | index 41 | ``` 42 | - Запрос к локейшену `/new-headers` (есть заголовки `Cache-Control` и `Pragma`, но нет `X-Frame-Options`): 43 | ```http 44 | GET /new-headers HTTP/1.0 45 | 46 | 47 | HTTP/1.1 200 OK 48 | Server: nginx/1.10.2 49 | Date: Mon, 09 Jan 2017 19:29:46 GMT 50 | Content-Type: application/octet-stream 51 | Content-Length: 11 52 | Connection: close 53 | Cache-Control: no-cache, no-store, max-age=0, must-revalidate 54 | Pragma: no-cache 55 | 56 | new-headers 57 | ``` 58 | 59 | ## Что делать? 60 | Существует несколько способов решить эту проблему: 61 | - продублировать важные заголовки; 62 | - устанавливать заголовки на одном уровне, например, в серверной секции; 63 | - использовать модуль [ngx_headers_more](https://www.nginx.com/resources/wiki/modules/headers_more/). 64 | 65 | Каждый из способов имеет свои преимущества и недостатки, какой предпочесть зависит от ваших потребностей. -------------------------------------------------------------------------------- /gixy/plugins/add_header_redefinition.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class add_header_redefinition(Plugin): 6 | """ 7 | Insecure example: 8 | server { 9 | add_header X-Content-Type-Options nosniff; 10 | location / { 11 | add_header X-Frame-Options DENY; 12 | } 13 | } 14 | """ 15 | summary = 'Nested "add_header" drops parent headers.' 16 | severity = gixy.severity.MEDIUM 17 | description = ('"add_header" replaces ALL parent headers. ' 18 | 'See documentation: http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header') 19 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/addheaderredefinition.md' 20 | directives = ['server', 'location', 'if'] 21 | options = {'headers': set(['x-frame-options', 22 | 'x-content-type-options', 23 | 'x-xss-protection', 24 | 'content-security-policy', 25 | 'cache-control']) 26 | } 27 | 28 | def __init__(self, config): 29 | super(add_header_redefinition, self).__init__(config) 30 | self.interesting_headers = self.config.get('headers') 31 | 32 | def audit(self, directive): 33 | if not directive.is_block: 34 | # Skip all not block directives 35 | return 36 | 37 | actual_headers = get_headers(directive) 38 | if not actual_headers: 39 | return 40 | 41 | for parent in directive.parents: 42 | parent_headers = get_headers(parent) 43 | if not parent_headers: 44 | continue 45 | 46 | diff = (parent_headers - actual_headers) & self.interesting_headers 47 | 48 | if len(diff): 49 | self._report_issue(directive, parent, diff) 50 | 51 | break 52 | 53 | def _report_issue(self, current, parent, diff): 54 | directives = [] 55 | # Add headers from parent level 56 | directives.extend(parent.find('add_header')) 57 | # Add headers from current level 58 | directives.extend(current.find('add_header')) 59 | reason = 'Parent headers "{headers}" was dropped in current level'.format(headers='", "'.join(diff)) 60 | self.add_issue(directive=directives, reason=reason) 61 | 62 | 63 | def get_headers(directive): 64 | headers = directive.find('add_header') 65 | if not headers: 66 | return set() 67 | 68 | return set(map(lambda d: d.header, headers)) 69 | -------------------------------------------------------------------------------- /docs/ru/plugins/httpsplitting.md: -------------------------------------------------------------------------------- 1 | # [http_splitting] HTTP Splitting 2 | 3 | HTTP Splitting - уязвимость, возникающая из-за неправильной обработки входных данных. 4 | Зачастую может быть для атак на приложение стоящее за Nginx (HTTP Request Splitting) или на клиентов приложения (HTTP Response Splitting). 5 | 6 | Уязвимость возникает в случае, когда атакующий может внедрить символ перевода строки `\n` или `\r` в запрос или ответ формируемый Nginx. 7 | 8 | ## Как самостоятельно обнаружить? 9 | При анализе конфигурации всега стоит обращать внимание на: 10 | - какие переменные используются в директивах, отвечающих за формирование запросов (могут ли они содержать CRLF), например: `rewrite`, `return`, `add_header`, `proxy_set_header` или `proxy_pass`; 11 | - используются ли переменные `$uri` и `$document_uri` и если да, то в каких директивах, т.к. они гарантированно содержат урлдекодированное значение; 12 | - переменные, выделенные из групп с исключающим диапазоном: `(?P[^.]+)`. 13 | 14 | Пример плохой конфигурации с переменной, полученной из группы с исключающим диапазоном: 15 | ```nginx 16 | server { 17 | listen 80 default; 18 | 19 | location ~ /v1/((?[^.]*)\.json)?$ { 20 | add_header X-Action $action; 21 | return 200 "OK"; 22 | } 23 | } 24 | ``` 25 | 26 | Пример эксплуатации данной конфигурации: 27 | ```http 28 | GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0 29 | Host: localhost 30 | 31 | HTTP/1.1 200 OK 32 | Server: nginx/1.11.10 33 | Date: Mon, 13 Mar 2017 21:21:29 GMT 34 | Content-Type: application/octet-stream 35 | Content-Length: 2 36 | Connection: close 37 | X-Action: see below 38 | x-crlf-header:injected 39 | 40 | OK 41 | ``` 42 | 43 | Из примера видно, что злоумышленник смог добавить заголовок ответа `x-crlf-header: injected`. Это случилось благодаря стечению нескольких обстоятельств: 44 | - `add_header` не кодирует/валидирует переданные ему значения, считая что автор знает о последствиях; 45 | - значение пути нормализуется перед обработкой локейшена; 46 | - переменная `$action` была выделена из группы регулярного выражения с исключающим диапазоном: `[^.]*`; 47 | - таким образом, значение переменной `$action` равно `see below\r\nx-crlf-header:injected` и при её использовании в формировании ответа добавился заголовок. 48 | 49 | ## Что делать? 50 | - старайтесь использовать более безопасные переменные, например, `$request_uri` вместо `$uri`; 51 | - запретите перевод строки в исключающем диапазоне, например, `/some/(?[^/\s]+)` вместо `/some/(?[^/]+`; 52 | - возможно, хорошей идеей будет добавить валидацию `$uri` (только если вы знаете, что делаете). 53 | -------------------------------------------------------------------------------- /gixy/core/plugins_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | 6 | 7 | class PluginsManager(object): 8 | def __init__(self, config=None): 9 | self.imported = False 10 | self.config = config 11 | self._plugins = [] 12 | 13 | def import_plugins(self): 14 | if self.imported: 15 | return 16 | 17 | files_list = os.listdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'plugins')) 18 | for plugin_file in files_list: 19 | if not plugin_file.endswith('.py') or plugin_file.startswith('_'): 20 | continue 21 | __import__('gixy.plugins.' + os.path.splitext(plugin_file)[0], None, None, ['']) 22 | 23 | self.imported = True 24 | 25 | def init_plugins(self): 26 | self.import_plugins() 27 | 28 | exclude = self.config.skips if self.config else None 29 | include = self.config.plugins if self.config else None 30 | severity = self.config.severity if self.config else None 31 | for plugin_cls in Plugin.__subclasses__(): 32 | name = plugin_cls.__name__ 33 | if include and name not in include: 34 | # Skip not needed plugins 35 | continue 36 | if exclude and name in exclude: 37 | # Skipped plugins 38 | continue 39 | if severity and not gixy.severity.is_acceptable(plugin_cls.severity, severity): 40 | # Skip plugin by severity level 41 | continue 42 | if self.config and self.config.has_for(name): 43 | options = self.config.get_for(name) 44 | else: 45 | options = plugin_cls.options 46 | self._plugins.append(plugin_cls(options)) 47 | 48 | @property 49 | def plugins(self): 50 | if not self._plugins: 51 | self.init_plugins() 52 | return self._plugins 53 | 54 | @property 55 | def plugins_classes(self): 56 | self.import_plugins() 57 | return Plugin.__subclasses__() 58 | 59 | def get_plugins_descriptions(self): 60 | return map(lambda a: a.name, self.plugins) 61 | 62 | def audit(self, directive): 63 | for plugin in self.plugins: 64 | if plugin.directives and directive.name not in plugin.directives: 65 | continue 66 | plugin.audit(directive) 67 | 68 | def issues(self): 69 | result = [] 70 | for plugin in self.plugins: 71 | if not plugin.issues: 72 | continue 73 | result.extend(plugin.issues) 74 | return result 75 | -------------------------------------------------------------------------------- /rpm/gixy.spec: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | 3 | Summary: Nginx configuration static analyzer 4 | Name: gixy 5 | Version: 0.1.5 6 | Release: 0%{?dist} 7 | License: MPLv2.0 8 | Group: Development/Utilities 9 | URL: https://github.com/yandex/gixy 10 | 11 | Source: https://github.com/yandex/%{name}/archive/v%{version}.tar.gz 12 | 13 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 14 | 15 | BuildArch: noarch 16 | 17 | BuildRequires: python-devel python-setuptools 18 | 19 | Requires: python-setuptools python-six >= 1.1.0 python-jinja >= 2.8 20 | Requires: python2-cached_property >= 1.2.0 python2-configargparse >= 0.11.0 21 | Requires: python-argparse >= 1.4.0 pyparsing >= 1.5.5 python-markupsafe 22 | 23 | Provides: %{name} = %{verion}-%{release} 24 | 25 | ######################################################################################## 26 | 27 | %description 28 | Gixy is a tool to analyze Nginx configuration. The main goal of Gixy is to prevent 29 | misconfiguration and automate flaw detection. 30 | 31 | ######################################################################################## 32 | 33 | %prep 34 | %setup -qn %{name}-%{version} 35 | 36 | %clean 37 | rm -rf %{buildroot} 38 | 39 | %build 40 | python setup.py build 41 | 42 | %install 43 | rm -rf %{buildroot} 44 | python setup.py install --prefix=%{_prefix} \ 45 | --root=%{buildroot} 46 | 47 | ######################################################################################## 48 | 49 | %files 50 | %defattr(-,root,root,-) 51 | %doc LICENSE AUTHORS README.md docs/* 52 | %{python_sitelib}/* 53 | %{_bindir}/%{name} 54 | 55 | ######################################################################################## 56 | 57 | %changelog 58 | * Sun May 21 2017 Yandex Team - 0.1.5-0 59 | - Supported Python 2.6 60 | - Supported multiple config files scanning 61 | - Fixed summary count 62 | - Fixed symlink resolution 63 | - Minor improvements and fixes 64 | 65 | * Sun May 14 2017 Yandex Team - 0.1.4-0 66 | - Allow processing stdin, file descriptors 67 | - Fixed configuration parser 68 | 69 | * Thu May 11 2017 Yandex Team - 0.1.3-0 70 | - Uses english versions in plugins references 71 | 72 | * Tue May 02 2017 Yandex Team - 0.1.2-0 73 | - Fixed blank comments parsing 74 | - Added "auth_request_set" directive 75 | 76 | * Sat Apr 29 2017 Yandex Team - 0.1.1-0 77 | - Initial build 78 | 79 | -------------------------------------------------------------------------------- /gixy/core/manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import gixy 5 | from gixy.core.plugins_manager import PluginsManager 6 | from gixy.core.context import get_context, pop_context, push_context, purge_context 7 | from gixy.parser.nginx_parser import NginxParser 8 | from gixy.core.config import Config 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class Manager(object): 14 | def __init__(self, config=None): 15 | self.root = None 16 | self.config = config or Config() 17 | self.auditor = PluginsManager(config=self.config) 18 | 19 | def audit(self, file_path, file_data, is_stdin=False): 20 | LOG.debug("Audit config file: {fname}".format(fname=file_path)) 21 | parser = NginxParser( 22 | cwd=os.path.dirname(file_path) if not is_stdin else '', 23 | allow_includes=self.config.allow_includes) 24 | self.root = parser.parse(content=file_data.read(), path_info=file_path) 25 | 26 | push_context(self.root) 27 | self._audit_recursive(self.root.children) 28 | 29 | @property 30 | def results(self): 31 | for plugin in self.auditor.plugins: 32 | if plugin.issues: 33 | yield plugin 34 | 35 | @property 36 | def stats(self): 37 | stats = dict.fromkeys(gixy.severity.ALL, 0) 38 | for plugin in self.auditor.plugins: 39 | base_severity = plugin.severity 40 | for issue in plugin.issues: 41 | # TODO(buglloc): encapsulate into Issue class? 42 | severity = issue.severity if issue.severity else base_severity 43 | stats[severity] += 1 44 | return stats 45 | 46 | def _audit_recursive(self, tree): 47 | for directive in tree: 48 | self._update_variables(directive) 49 | self.auditor.audit(directive) 50 | if directive.is_block: 51 | if directive.self_context: 52 | push_context(directive) 53 | self._audit_recursive(directive.children) 54 | if directive.self_context: 55 | pop_context() 56 | 57 | def _update_variables(self, directive): 58 | # TODO(buglloc): finish him! 59 | if not directive.provide_variables: 60 | return 61 | 62 | context = get_context() 63 | for var in directive.variables: 64 | if var.name == 0: 65 | # All regexps must clean indexed variables 66 | context.clear_index_vars() 67 | context.add_var(var.name, var) 68 | 69 | def __enter__(self): 70 | return self 71 | 72 | def __exit__(self, exc_type, exc_val, exc_tb): 73 | purge_context() 74 | -------------------------------------------------------------------------------- /docs/en/plugins/ssrf.md: -------------------------------------------------------------------------------- 1 | # [ssrf] Server Side Request Forgery 2 | 3 | Server Side Request Forgery - attack that forces a server to perform arbitrary requests (from Nginx in our case). 4 | It's possible when an attacker controls the address of a proxied server (second argument of the `proxy_pass` directive). 5 | 6 | 7 | ## How can I find it? 8 | There are two types of errors that make a server vulnerable: 9 | - lack of the [internal](http://nginx.org/en/docs/http/ngx_http_core_module.html#internal) directive. It is used to point out a location that can be used for internal requests only; 10 | - unsafe internal redirection. 11 | 12 | ### Lack of the internal directive 13 | Classical misconfiguration, based on lack of the `internal` directive, that makes SSRF possible: 14 | ```nginx 15 | location ~ /proxy/(.*)/(.*)/(.*)$ { 16 | proxy_pass $1://$2/$3; 17 | } 18 | ``` 19 | 20 | An attacker has complete control over the proxied address, that makes sending requests on behalf of Nginx possible. 21 | 22 | ### Unsafe internal redirection 23 | Let's say you have internal location in your config and that location uses some request data as proxied server's address. 24 | 25 | E.g.: 26 | ```nginx 27 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 28 | internal; 29 | 30 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 31 | proxy_set_header Host $proxy_host; 32 | } 33 | ``` 34 | 35 | According to Nginx docs, internal requests are the following: 36 | > - requests redirected by the **error_page**, index, random_index, and **try_files** directives; 37 | > - requests redirected by the “X-Accel-Redirect” response header field from an upstream server; 38 | > - subrequests formed by the “include virtual” command of the ngx_http_ssi_module module and by the ngx_http_addition_module module directives; 39 | > - requests changed by the **rewrite** directive 40 | 41 | Accordingly, any unsafe rewrite allows an attacker to make an internal request and control a proxied server's address. 42 | 43 | Misconfiguration example: 44 | ```nginx 45 | rewrite ^/(.*)/some$ /$1/ last; 46 | 47 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 48 | internal; 49 | 50 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 51 | proxy_set_header Host $proxy_host; 52 | } 53 | ``` 54 | 55 | ## What can I do? 56 | There are several rules you better follow when writing such configurations: 57 | - use only "internal locations" for proxying; 58 | - if possible, forbid user data transmission; 59 | - protect proxied server's address: 60 | * if the quantity of proxied hosts is limited (when you have S3 or smth), you better hardcode them and choose them with `map` or do it some other way; 61 | * if you can' list all possible hosts to proxy, you should sign the address. 62 | -------------------------------------------------------------------------------- /rpm/python-argparse.spec: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | 3 | %{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")} 4 | 5 | ######################################################################################## 6 | 7 | %define pkg_name argparse 8 | %define pkg_version r140 9 | 10 | ######################################################################################## 11 | 12 | Summary: Python command-line parsing library 13 | Name: python-argparse 14 | Version: 1.4.0 15 | Release: 0%{?dist} 16 | License: Python License 17 | Group: Development/Libraries 18 | URL: https://github.com/ThomasWaldmann/argparse 19 | 20 | Source: https://github.com/ThomasWaldmann/%{pkg_name}/archive/%{pkg_version}.tar.gz 21 | 22 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 23 | 24 | BuildArch: noarch 25 | 26 | BuildRequires: python >= 2.3 python-setuptools 27 | 28 | Requires: python >= 2.3 python-setuptools 29 | 30 | Provides: %{name} = %{verion}-%{release} 31 | 32 | ######################################################################################## 33 | 34 | %description 35 | The argparse module makes it easy to write user friendly command line interfaces. 36 | 37 | The program defines what arguments it requires, and argparse will figure out 38 | how to parse those out of sys.argv. The argparse module also automatically 39 | generates help and usage messages and issues errors when users give the program 40 | invalid arguments. 41 | 42 | As of Python >= 2.7 and >= 3.2, the argparse module is maintained within the 43 | Python standard library. For users who still need to support Python < 2.7 or 44 | < 3.2, it is also provided as a separate package, which tries to stay 45 | compatible with the module in the standard library, but also supports older 46 | Python versions. 47 | 48 | argparse is licensed under the Python license, for details see LICENSE.txt. 49 | 50 | ######################################################################################## 51 | 52 | %prep 53 | %setup -qn %{pkg_name}-%{pkg_version} 54 | 55 | %clean 56 | rm -rf %{buildroot} 57 | 58 | %build 59 | python setup.py build 60 | 61 | %install 62 | rm -rf %{buildroot} 63 | python setup.py install --prefix=%{_prefix} \ 64 | --single-version-externally-managed -O1 \ 65 | --root=%{buildroot} 66 | 67 | ######################################################################################## 68 | 69 | %files 70 | %defattr(-,root,root,-) 71 | %doc LICENSE.txt NEWS.txt README.txt 72 | %{python_sitelib}/* 73 | 74 | ######################################################################################## 75 | 76 | %changelog 77 | * Sat Apr 29 2017 Yandex Team - 1.4.0-0 78 | - Initial build 79 | 80 | -------------------------------------------------------------------------------- /gixy/plugins/origins.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | from gixy.core.regexp import Regexp 6 | 7 | LOG = logging.getLogger(__name__) 8 | 9 | 10 | class origins(Plugin): 11 | r""" 12 | Insecure example: 13 | if ($http_referer !~ "^https?://([^/]+metrika.*yandex\.ru/"){ 14 | add_header X-Frame-Options SAMEORIGIN; 15 | } 16 | """ 17 | summary = 'Validation regex for "origin" or "referrer" matches untrusted domain.' 18 | severity = gixy.severity.MEDIUM 19 | description = 'Improve the regular expression to match only trusted referrers.' 20 | help_url = 'https://github.com/yandex/gixy/blob/master/docs/en/plugins/origins.md' 21 | directives = ['if'] 22 | options = { 23 | 'domains': ['*'], 24 | 'https_only': False 25 | } 26 | 27 | def __init__(self, config): 28 | super(origins, self).__init__(config) 29 | if self.config.get('domains') and self.config.get('domains')[0] and self.config.get('domains')[0] != '*': 30 | domains = '|'.join(re.escape(d) for d in self.config.get('domains')) 31 | else: 32 | domains = r'[^/.]*\.[^/]{2,7}' 33 | 34 | scheme = 'https{http}'.format(http=('?' if not self.config.get('https_only') else '')) 35 | regex = r'^{scheme}://(?:[^/.]*\.){{0,10}}(?P{domains})(?::\d*)?(?:/|\?|$)'.format( 36 | scheme=scheme, 37 | domains=domains 38 | ) 39 | self.valid_re = re.compile(regex) 40 | 41 | def audit(self, directive): 42 | if directive.operand not in ['~', '~*', '!~', '!~*']: 43 | # Not regexp 44 | return 45 | 46 | if directive.variable not in ['$http_referer', '$http_origin']: 47 | # Not interesting 48 | return 49 | 50 | invalid_referers = set() 51 | regexp = Regexp(directive.value, case_sensitive=(directive.operand in ['~', '!~'])) 52 | for value in regexp.generate('/', anchored=True): 53 | if value.startswith('^'): 54 | value = value[1:] 55 | else: 56 | value = 'http://evil.com/' + value 57 | 58 | if value.endswith('$'): 59 | value = value[:-1] 60 | elif not value.endswith('/'): 61 | value += '.evil.com' 62 | 63 | valid = self.valid_re.match(value) 64 | if not valid or valid.group('domain') == 'evil.com': 65 | invalid_referers.add(value) 66 | 67 | if invalid_referers: 68 | invalid_referers = '", "'.join(invalid_referers) 69 | name = 'origin' if directive.variable == '$http_origin' else 'referrer' 70 | severity = gixy.severity.HIGH if directive.variable == '$http_origin' else gixy.severity.MEDIUM 71 | reason = 'Regex matches "{value}" as a valid {name}.'.format(value=invalid_referers, name=name) 72 | self.add_issue(directive=directive, reason=reason, severity=severity) 73 | -------------------------------------------------------------------------------- /docs/ru/plugins/ssrf.md: -------------------------------------------------------------------------------- 1 | # [ssrf] Server Side Request Forgery 2 | 3 | Server Side Request Forgery - уязвимость, позволяющая выполнять различного рода запросы от имени веб-приложения (в нашем случае от имени Nginx). 4 | Возникает, когда атакующий может контролировать адрес проксируемого сервера (второй аргумент директивы `proxy_pass`). 5 | 6 | 7 | ## Как самостоятельно обнаружить? 8 | Наиболее распространенно два класса ошибок конфигурации, которые приводят к этой проблеме: 9 | - отсутствие директивы [internal](http://nginx.org/ru/docs/http/ngx_http_core_module.html#internal). Её смысл заключается в указании того, что определенный location может использоваться только для внутренних запросов; 10 | - небезопасное внутреннее перенаправление. 11 | 12 | ### Отсутствие директивы internal 13 | Классический пример уязвимости типа SSRF в виду отсутствия директивы `internal` выглядит следующим образом: 14 | ```nginx 15 | location ~ /proxy/(.*)/(.*)/(.*)$ { 16 | proxy_pass $1://$2/$3; 17 | } 18 | ``` 19 | Злоумышленник, полностью контролируя адрес проксируемого сервера, может выполнять произвольные запросы от имени Nginx. 20 | 21 | ### Небезопасное внутреннее перенаправление 22 | Подразумевается, что в вашей конфигурации есть internal location, которые использует какие-либо данные из запроса в качестве адреса проксируемого сервера. 23 | 24 | Например: 25 | ```nginx 26 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 27 | internal; 28 | 29 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 30 | proxy_set_header Host $proxy_host; 31 | } 32 | ``` 33 | 34 | Согласно документации Nginx внутренними запросами являются: 35 | > - запросы, перенаправленные директивами **error_page**, index, random_index и **try_files**; 36 | > - запросы, перенаправленные с помощью поля “X-Accel-Redirect” заголовка ответа вышестоящего сервера; 37 | > - подзапросы, формируемые командой “include virtual” модуля ngx_http_ssi_module и директивами модуля ngx_http_addition_module; 38 | > - запросы, изменённые директивой **rewrite**.]> 39 | 40 | Соответственно, любой "неосторожный" реврайт позволит злоумышленнику сделать внутренний запрос и контролировать адрес проксируемого сервера. 41 | 42 | Пример плохой конфигурации: 43 | ```nginx 44 | rewrite ^/(.*)/some$ /$1/ last; 45 | 46 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 47 | internal; 48 | 49 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 50 | proxy_set_header Host $proxy_host; 51 | } 52 | ``` 53 | 54 | ## Что делать? 55 | Есть несколько правил, которых стоит придерживаться в подобного рода конфигурациях: 56 | - использовать только internal location для проксирования; 57 | - по возможности запретить передачу пользовательских данных; 58 | - обезопасить адрес проксируемого сервера: 59 | * если количество проксируемых хостов ограниченно (например, у вас S3), то лучше их захардкодить и выбирать при помощи `map` или иным удобным для вас образом; 60 | * если по какой-то причине нет возможности перечислить все возможные хосты для проксирования, его стоит подписать. 61 | -------------------------------------------------------------------------------- /tests/parser/test_nginx_parser.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_equal 2 | from tests.asserts import assert_is_instance 3 | from gixy.parser.nginx_parser import NginxParser 4 | from gixy.directives.directive import * 5 | from gixy.directives.block import * 6 | 7 | 8 | def _parse(config): 9 | return NginxParser(cwd='', allow_includes=False).parse(config) 10 | 11 | 12 | def test_directive(): 13 | configs = [ 14 | 'access_log syslog:server=127.0.0.1,tag=nginx_sentry toolsformat;', 15 | 'user http;', 16 | 'internal;', 17 | 'set $foo "bar";', 18 | "set $foo 'bar';", 19 | 'proxy_pass http://unix:/run/sock.socket;', 20 | 'rewrite ^/([a-zA-Z0-9]+)$ /$1/${arg_v}.pb break;' 21 | ] 22 | 23 | expected = [ 24 | [Directive], 25 | [Directive], 26 | [Directive], 27 | [Directive, SetDirective], 28 | [Directive], 29 | [Directive, RewriteDirective] 30 | ] 31 | 32 | for i, config in enumerate(configs): 33 | return assert_config, config, expected[i] 34 | 35 | 36 | def test_blocks(): 37 | configs = [ 38 | 'if (-f /some) {}', 39 | 'location / {}' 40 | ] 41 | 42 | expected = [ 43 | [Directive, Block, IfBlock], 44 | [Directive, Block, LocationBlock], 45 | ] 46 | 47 | for i, config in enumerate(configs): 48 | yield assert_config, config, expected[i] 49 | 50 | 51 | def test_dump_simple(): 52 | config = ''' 53 | # configuration file /etc/nginx/nginx.conf: 54 | http { 55 | include sites/*.conf; 56 | } 57 | 58 | # configuration file /etc/nginx/conf.d/listen: 59 | listen 80; 60 | 61 | # configuration file /etc/nginx/sites/default.conf: 62 | server { 63 | include conf.d/listen; 64 | } 65 | ''' 66 | 67 | tree = _parse(config) 68 | assert_is_instance(tree, Directive) 69 | assert_is_instance(tree, Block) 70 | assert_is_instance(tree, Root) 71 | 72 | assert_equal(len(tree.children), 1) 73 | http = tree.children[0] 74 | assert_is_instance(http, Directive) 75 | assert_is_instance(http, Block) 76 | assert_is_instance(http, HttpBlock) 77 | 78 | assert_equal(len(http.children), 1) 79 | include_server = http.children[0] 80 | assert_is_instance(include_server, Directive) 81 | assert_is_instance(include_server, IncludeBlock) 82 | assert_equal(include_server.file_path, '/etc/nginx/sites/default.conf') 83 | 84 | assert_equal(len(include_server.children), 1) 85 | server = include_server.children[0] 86 | assert_is_instance(server, Directive) 87 | assert_is_instance(server, Block) 88 | assert_is_instance(server, ServerBlock) 89 | 90 | assert_equal(len(server.children), 1) 91 | include_listen = server.children[0] 92 | assert_is_instance(include_listen, Directive) 93 | assert_is_instance(include_listen, IncludeBlock) 94 | assert_equal(include_listen.file_path, '/etc/nginx/conf.d/listen') 95 | 96 | assert_equal(len(include_listen.children), 1) 97 | listen = include_listen.children[0] 98 | assert_is_instance(listen, Directive) 99 | assert_equal(listen.args, ['80']) 100 | 101 | 102 | def test_encoding(): 103 | configs = [ 104 | 'bar "\xD1\x82\xD0\xB5\xD1\x81\xD1\x82";' 105 | ] 106 | 107 | for i, config in enumerate(configs): 108 | _parse(config) 109 | 110 | 111 | def assert_config(config, expected): 112 | tree = _parse(config) 113 | assert_is_instance(tree, Directive) 114 | assert_is_instance(tree, Block) 115 | assert_is_instance(tree, Root) 116 | 117 | child = tree.children[0] 118 | for ex in expected: 119 | assert_is_instance(child, ex) 120 | -------------------------------------------------------------------------------- /tests/core/test_variable.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_true, assert_false, assert_equals, with_setup 2 | from gixy.core.context import get_context, push_context, purge_context 3 | from gixy.directives.block import Root 4 | from gixy.core.regexp import Regexp 5 | from gixy.core.variable import Variable 6 | 7 | def setup(): 8 | push_context(Root()) 9 | 10 | 11 | def tear_down(): 12 | purge_context() 13 | 14 | 15 | @with_setup(setup, tear_down) 16 | def test_literal(): 17 | var = Variable(name='simple', value='$uri', have_script=False) 18 | assert_false(var.depends) 19 | assert_false(var.regexp) 20 | assert_equals(var.value, '$uri') 21 | 22 | assert_false(var.can_startswith('$')) 23 | assert_false(var.can_contain('i')) 24 | assert_true(var.must_contain('$')) 25 | assert_true(var.must_contain('u')) 26 | assert_false(var.must_contain('a')) 27 | assert_true(var.must_startswith('$')) 28 | assert_false(var.must_startswith('u')) 29 | 30 | 31 | @with_setup(setup, tear_down) 32 | def test_regexp(): 33 | var = Variable(name='simple', value=Regexp('^/.*')) 34 | assert_false(var.depends) 35 | assert_true(var.regexp) 36 | 37 | assert_true(var.can_startswith('/')) 38 | assert_false(var.can_startswith('a')) 39 | assert_true(var.can_contain('a')) 40 | assert_false(var.can_contain('\n')) 41 | assert_true(var.must_contain('/')) 42 | assert_false(var.must_contain('a')) 43 | assert_true(var.must_startswith('/')) 44 | assert_false(var.must_startswith('a')) 45 | 46 | 47 | @with_setup(setup, tear_down) 48 | def test_script(): 49 | get_context().add_var('foo', Variable(name='foo', value=Regexp('.*'))) 50 | var = Variable(name='simple', value='/$foo') 51 | assert_true(var.depends) 52 | assert_false(var.regexp) 53 | 54 | assert_false(var.can_startswith('/')) 55 | assert_false(var.can_startswith('a')) 56 | assert_true(var.can_contain('/')) 57 | assert_true(var.can_contain('a')) 58 | assert_false(var.can_contain('\n')) 59 | assert_true(var.must_contain('/')) 60 | assert_false(var.must_contain('a')) 61 | assert_true(var.must_startswith('/')) 62 | assert_false(var.must_startswith('a')) 63 | 64 | 65 | @with_setup(setup, tear_down) 66 | def test_regexp_boundary(): 67 | var = Variable(name='simple', value=Regexp('.*'), boundary=Regexp('/[a-z]', strict=True)) 68 | assert_false(var.depends) 69 | assert_true(var.regexp) 70 | 71 | assert_true(var.can_startswith('/')) 72 | assert_false(var.can_startswith('a')) 73 | assert_false(var.can_contain('/')) 74 | assert_true(var.can_contain('a')) 75 | assert_false(var.can_contain('0')) 76 | assert_false(var.can_contain('\n')) 77 | assert_true(var.must_contain('/')) 78 | assert_false(var.must_contain('a')) 79 | assert_true(var.must_startswith('/')) 80 | assert_false(var.must_startswith('a')) 81 | 82 | 83 | @with_setup(setup, tear_down) 84 | def test_script_boundary(): 85 | get_context().add_var('foo', Variable(name='foo', value=Regexp('.*'), boundary=Regexp('[a-z]', strict=True))) 86 | var = Variable(name='simple', value='/$foo', boundary=Regexp('[/a-z0-9]', strict=True)) 87 | assert_true(var.depends) 88 | assert_false(var.regexp) 89 | 90 | assert_false(var.can_startswith('/')) 91 | assert_false(var.can_startswith('a')) 92 | assert_false(var.can_contain('/')) 93 | assert_true(var.can_contain('a')) 94 | assert_false(var.can_contain('\n')) 95 | assert_false(var.can_contain('0')) 96 | assert_true(var.must_contain('/')) 97 | assert_false(var.must_contain('a')) 98 | assert_true(var.must_startswith('/')) 99 | assert_false(var.must_startswith('a')) 100 | -------------------------------------------------------------------------------- /gixy/formatters/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import gixy 4 | from gixy.directives import block 5 | 6 | 7 | class BaseFormatter(object): 8 | skip_parents = set([block.Root, block.HttpBlock]) 9 | 10 | def __init__(self): 11 | self.reports = {} 12 | self.stats = dict.fromkeys(gixy.severity.ALL, 0) 13 | 14 | def format_reports(self, reports, stats): 15 | raise NotImplementedError("Formatter must override format_reports function") 16 | 17 | def feed(self, path, manager): 18 | for severity in gixy.severity.ALL: 19 | self.stats[severity] += manager.stats[severity] 20 | 21 | self.reports[path] = [] 22 | for result in manager.results: 23 | report = self._prepare_result(manager.root, 24 | summary=result.summary, 25 | severity=result.severity, 26 | description=result.description, 27 | issues=result.issues, 28 | plugin=result.name, 29 | help_url=result.help_url) 30 | self.reports[path].extend(report) 31 | 32 | def flush(self): 33 | return self.format_reports(self.reports, self.stats) 34 | 35 | def _prepare_result(self, root, issues, severity, summary, description, plugin, help_url): 36 | result = {} 37 | for issue in issues: 38 | report = dict( 39 | plugin=plugin, 40 | summary=issue.summary or summary, 41 | severity=issue.severity or severity, 42 | description=issue.description or description, 43 | help_url=issue.help_url or help_url, 44 | reason=issue.reason or '', 45 | ) 46 | key = ''.join(report.values()) 47 | report['directives'] = issue.directives 48 | if key in result: 49 | result[key]['directives'].extend(report['directives']) 50 | else: 51 | result[key] = report 52 | 53 | for report in result.values(): 54 | if report['directives']: 55 | config = self._resolve_config(root, report['directives']) 56 | else: 57 | config = '' 58 | 59 | del report['directives'] 60 | report['config'] = config 61 | yield report 62 | 63 | def _resolve_config(self, root, directives): 64 | points = set() 65 | for directive in directives: 66 | points.add(directive) 67 | points.update(p for p in directive.parents) 68 | 69 | result = self._traverse_tree(root, points, 0) 70 | return '\n'.join(result) 71 | 72 | def _traverse_tree(self, tree, points, level): 73 | result = [] 74 | for leap in tree.children: 75 | if leap not in points: 76 | continue 77 | printable = type(leap) not in self.skip_parents 78 | # Special hack for includes 79 | # TODO(buglloc): fix me 80 | have_parentheses = type(leap) != block.IncludeBlock 81 | 82 | if printable: 83 | if leap.is_block: 84 | result.append('') 85 | directive = str(leap).replace('\n', '\n' + '\t' * (level + 1)) 86 | result.append('{indent:s}{dir:s}'.format(indent='\t' * level, dir=directive)) 87 | 88 | if leap.is_block: 89 | result.extend(self._traverse_tree(leap, points, level + 1 if printable else level)) 90 | if printable and have_parentheses: 91 | result.append('{indent:s}}}'.format(indent='\t' * level)) 92 | 93 | return result 94 | -------------------------------------------------------------------------------- /tests/directives/test_directive.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_equals, assert_false, assert_true 2 | from tests.asserts import assert_is_instance 3 | from gixy.parser.nginx_parser import NginxParser 4 | from gixy.directives.directive import * 5 | 6 | 7 | def _get_parsed(config): 8 | root = NginxParser(cwd='', allow_includes=False).parse(config) 9 | return root.children[0] 10 | 11 | 12 | def test_directive(): 13 | config = 'some "foo" "bar";' 14 | 15 | directive = _get_parsed(config) 16 | assert_is_instance(directive, Directive) 17 | assert_equals(directive.name, 'some') 18 | assert_equals(directive.args, ['foo', 'bar']) 19 | assert_equals(str(directive), 'some foo bar;') 20 | 21 | 22 | def test_add_header(): 23 | config = 'add_header "X-Foo" "bar";' 24 | 25 | directive = _get_parsed(config) 26 | assert_is_instance(directive, AddHeaderDirective) 27 | assert_equals(directive.name, 'add_header') 28 | assert_equals(directive.args, ['X-Foo', 'bar']) 29 | assert_equals(directive.header, 'x-foo') 30 | assert_equals(directive.value, 'bar') 31 | assert_false(directive.always) 32 | assert_equals(str(directive), 'add_header X-Foo bar;') 33 | 34 | 35 | def test_add_header_always(): 36 | config = 'add_header "X-Foo" "bar" always;' 37 | 38 | directive = _get_parsed(config) 39 | assert_is_instance(directive, AddHeaderDirective) 40 | assert_equals(directive.name, 'add_header') 41 | assert_equals(directive.args, ['X-Foo', 'bar', 'always']) 42 | assert_equals(directive.header, 'x-foo') 43 | assert_equals(directive.value, 'bar') 44 | assert_true(directive.always) 45 | assert_equals(str(directive), 'add_header X-Foo bar always;') 46 | 47 | 48 | def test_set(): 49 | config = 'set $foo bar;' 50 | 51 | directive = _get_parsed(config) 52 | assert_is_instance(directive, SetDirective) 53 | assert_equals(directive.name, 'set') 54 | assert_equals(directive.args, ['$foo', 'bar']) 55 | assert_equals(directive.variable, 'foo') 56 | assert_equals(directive.value, 'bar') 57 | assert_equals(str(directive), 'set $foo bar;') 58 | assert_true(directive.provide_variables) 59 | 60 | 61 | def test_rewrite(): 62 | config = 'rewrite ^ http://some;' 63 | 64 | directive = _get_parsed(config) 65 | assert_is_instance(directive, RewriteDirective) 66 | assert_equals(directive.name, 'rewrite') 67 | assert_equals(directive.args, ['^', 'http://some']) 68 | assert_equals(str(directive), 'rewrite ^ http://some;') 69 | assert_true(directive.provide_variables) 70 | 71 | assert_equals(directive.pattern, '^') 72 | assert_equals(directive.replace, 'http://some') 73 | assert_equals(directive.flag, None) 74 | 75 | 76 | def test_rewrite_flags(): 77 | config = 'rewrite ^/(.*)$ http://some/$1 redirect;' 78 | 79 | directive = _get_parsed(config) 80 | assert_is_instance(directive, RewriteDirective) 81 | assert_equals(directive.name, 'rewrite') 82 | assert_equals(directive.args, ['^/(.*)$', 'http://some/$1', 'redirect']) 83 | assert_equals(str(directive), 'rewrite ^/(.*)$ http://some/$1 redirect;') 84 | assert_true(directive.provide_variables) 85 | 86 | assert_equals(directive.pattern, '^/(.*)$') 87 | assert_equals(directive.replace, 'http://some/$1') 88 | assert_equals(directive.flag, 'redirect') 89 | 90 | 91 | def test_root(): 92 | config = 'root /var/www/html;' 93 | 94 | directive = _get_parsed(config) 95 | assert_is_instance(directive, RootDirective) 96 | assert_equals(directive.name, 'root') 97 | assert_equals(directive.args, ['/var/www/html']) 98 | assert_equals(str(directive), 'root /var/www/html;') 99 | assert_true(directive.provide_variables) 100 | 101 | assert_equals(directive.path, '/var/www/html') 102 | -------------------------------------------------------------------------------- /tests/plugins/test_simply.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_equals, assert_true 2 | from tests.asserts import assert_in 3 | import os 4 | from os import path 5 | import json 6 | 7 | from ..utils import * 8 | from gixy.core.manager import Manager as Gixy 9 | from gixy.core.plugins_manager import PluginsManager 10 | from gixy.core.config import Config 11 | 12 | 13 | def setup_module(): 14 | pass 15 | 16 | 17 | def teardown_module(): 18 | pass 19 | 20 | 21 | def test_from_config(): 22 | tested_plugins = set() 23 | tested_fp_plugins = set() 24 | 25 | conf_dir = path.join(path.dirname(__file__), 'simply') 26 | for plugin in os.listdir(conf_dir): 27 | if plugin in ('.', '..'): 28 | continue 29 | 30 | plugin_path = path.join(conf_dir, plugin) 31 | if not path.isdir(plugin_path): 32 | continue 33 | 34 | config = {} 35 | if path.exists(path.join(plugin_path, 'config.json')): 36 | with open(path.join(plugin_path, 'config.json'), 'r') as file: 37 | config = json.loads(file.read()) 38 | 39 | for test_case in os.listdir(plugin_path): 40 | if not test_case.endswith('.conf'): 41 | continue 42 | 43 | config_path = path.join(plugin_path, test_case) 44 | if not test_case.endswith('_fp.conf'): 45 | # Not False Positive test 46 | tested_plugins.add(plugin) 47 | test_func = check_configuration 48 | else: 49 | tested_fp_plugins.add(plugin) 50 | test_func = check_configuration_fp 51 | 52 | yield test_func, plugin, config_path, config 53 | 54 | manager = PluginsManager() 55 | for plugin in manager.plugins: 56 | plugin = plugin.name 57 | assert_true(plugin in tested_plugins, 58 | 'Plugin {name!r} should have at least one simple test config'.format(name=plugin)) 59 | assert_true(plugin in tested_fp_plugins, 60 | 'Plugin {name!r} should have at least one simple test config with false positive'.format(name=plugin)) 61 | 62 | 63 | def parse_plugin_options(config_path): 64 | with open(config_path, 'r') as f: 65 | config_line = f.readline() 66 | if config_line.startswith('# Options: '): 67 | return json.loads(config_line[10:]) 68 | return None 69 | 70 | 71 | def yoda_provider(plugin, plugin_options=None): 72 | config = Config( 73 | allow_includes=False, 74 | plugins=[plugin] 75 | ) 76 | if plugin_options: 77 | config.set_for(plugin, plugin_options) 78 | return Gixy(config=config) 79 | 80 | 81 | def check_configuration(plugin, config_path, test_config): 82 | plugin_options = parse_plugin_options(config_path) 83 | with yoda_provider(plugin, plugin_options) as yoda: 84 | yoda.audit(config_path, open(config_path, mode='r')) 85 | formatter = BaseFormatter() 86 | formatter.feed(config_path, yoda) 87 | _, results = formatter.reports.popitem() 88 | 89 | assert_equals(len(results), 1, 'Should have one report') 90 | result = results[0] 91 | 92 | if 'severity' in test_config: 93 | if not hasattr(test_config['severity'], '__iter__'): 94 | assert_equals(result['severity'], test_config['severity']) 95 | else: 96 | assert_in(result['severity'], test_config['severity']) 97 | assert_equals(result['plugin'], plugin) 98 | assert_true(result['summary']) 99 | assert_true(result['description']) 100 | assert_true(result['config']) 101 | assert_true(result['help_url'].startswith('https://'), 102 | 'help_url must starts with https://. It\'is URL!') 103 | 104 | 105 | def check_configuration_fp(plugin, config_path, test_config): 106 | with yoda_provider(plugin) as yoda: 107 | yoda.audit(config_path, open(config_path, mode='r')) 108 | assert_equals(len([x for x in yoda.results]), 0, 109 | 'False positive configuration must not trigger any plugins') 110 | -------------------------------------------------------------------------------- /gixy/directives/directive.py: -------------------------------------------------------------------------------- 1 | from gixy.core.variable import Variable 2 | from gixy.core.regexp import Regexp 3 | 4 | 5 | def get_overrides(): 6 | result = {} 7 | for klass in Directive.__subclasses__(): 8 | if not klass.nginx_name: 9 | continue 10 | 11 | if not klass.__name__.endswith('Directive'): 12 | continue 13 | 14 | result[klass.nginx_name] = klass 15 | return result 16 | 17 | 18 | class Directive(object): 19 | nginx_name = None 20 | is_block = False 21 | provide_variables = False 22 | 23 | def __init__(self, name, args, raw=None): 24 | self.name = name 25 | self.parent = None 26 | self.args = args 27 | self._raw = raw 28 | 29 | def set_parent(self, parent): 30 | self.parent = parent 31 | 32 | @property 33 | def parents(self): 34 | parent = self.parent 35 | while parent: 36 | yield parent 37 | parent = parent.parent 38 | 39 | @property 40 | def variables(self): 41 | raise NotImplementedError() 42 | 43 | def __str__(self): 44 | return '{name} {args};'.format(name=self.name, args=' '.join(self.args)) 45 | 46 | 47 | class AddHeaderDirective(Directive): 48 | nginx_name = 'add_header' 49 | 50 | def __init__(self, name, args): 51 | super(AddHeaderDirective, self).__init__(name, args) 52 | self.header = args[0].lower() 53 | self.value = args[1] 54 | self.always = False 55 | if len(args) > 2 and args[2] == 'always': 56 | self.always = True 57 | 58 | 59 | class SetDirective(Directive): 60 | nginx_name = 'set' 61 | provide_variables = True 62 | 63 | def __init__(self, name, args): 64 | super(SetDirective, self).__init__(name, args) 65 | self.variable = args[0].strip('$') 66 | self.value = args[1] 67 | 68 | @property 69 | def variables(self): 70 | return [Variable(name=self.variable, value=self.value, provider=self)] 71 | 72 | 73 | class AuthRequestSetDirective(Directive): 74 | nginx_name = 'auth_request_set' 75 | provide_variables = True 76 | 77 | def __init__(self, name, args): 78 | super(AuthRequestSetDirective, self).__init__(name, args) 79 | self.variable = args[0].strip('$') 80 | self.value = args[1] 81 | 82 | @property 83 | def variables(self): 84 | return [Variable(name=self.variable, value=self.value, provider=self)] 85 | 86 | 87 | class PerlSetDirective(Directive): 88 | nginx_name = 'perl_set' 89 | provide_variables = True 90 | 91 | def __init__(self, name, args): 92 | super(PerlSetDirective, self).__init__(name, args) 93 | self.variable = args[0].strip('$') 94 | self.value = args[1] 95 | 96 | @property 97 | def variables(self): 98 | return [Variable(name=self.variable, provider=self, have_script=False)] 99 | 100 | 101 | class SetByLuaDirective(Directive): 102 | nginx_name = 'set_by_lua' 103 | provide_variables = True 104 | 105 | def __init__(self, name, args): 106 | super(SetByLuaDirective, self).__init__(name, args) 107 | self.variable = args[0].strip('$') 108 | self.value = args[1] 109 | 110 | @property 111 | def variables(self): 112 | return [Variable(name=self.variable, provider=self, have_script=False)] 113 | 114 | 115 | class RewriteDirective(Directive): 116 | nginx_name = 'rewrite' 117 | provide_variables = True 118 | boundary = Regexp(r'[^\s\r\n]') 119 | 120 | def __init__(self, name, args): 121 | super(RewriteDirective, self).__init__(name, args) 122 | self.pattern = args[0] 123 | self.replace = args[1] 124 | self.flag = None 125 | if len(args) > 2: 126 | self.flag = args[2] 127 | 128 | @property 129 | def variables(self): 130 | regexp = Regexp(self.pattern, case_sensitive=True) 131 | result = [] 132 | for name, group in regexp.groups.items(): 133 | result.append(Variable(name=name, value=group, boundary=self.boundary, provider=self)) 134 | return result 135 | 136 | 137 | class RootDirective(Directive): 138 | nginx_name = 'root' 139 | provide_variables = True 140 | 141 | def __init__(self, name, args): 142 | super(RootDirective, self).__init__(name, args) 143 | self.path = args[0] 144 | 145 | @property 146 | def variables(self): 147 | return [Variable(name='document_root', value=self.path, provider=self)] 148 | 149 | 150 | class AliasDirective(Directive): 151 | nginx_name = 'alias' 152 | 153 | def __init__(self, name, args): 154 | super(AliasDirective, self).__init__(name, args) 155 | self.path = args[0] 156 | -------------------------------------------------------------------------------- /tests/core/test_context.py: -------------------------------------------------------------------------------- 1 | from nose.tools import with_setup, assert_equals, assert_not_equals, assert_true 2 | from gixy.core.context import get_context, pop_context, push_context, purge_context, CONTEXTS, Context 3 | from gixy.directives.block import Root 4 | from gixy.core.variable import Variable 5 | from gixy.core.regexp import Regexp 6 | 7 | 8 | def setup(): 9 | assert_equals(len(CONTEXTS), 0) 10 | 11 | 12 | def tear_down(): 13 | purge_context() 14 | 15 | 16 | @with_setup(setup, tear_down) 17 | def test_push_pop_context(): 18 | root_a = Root() 19 | push_context(root_a) 20 | assert_equals(len(CONTEXTS), 1) 21 | root_b = Root() 22 | push_context(root_b) 23 | assert_equals(len(CONTEXTS), 2) 24 | 25 | poped = pop_context() 26 | assert_equals(len(CONTEXTS), 1) 27 | assert_equals(poped.block, root_b) 28 | poped = pop_context() 29 | assert_equals(len(CONTEXTS), 0) 30 | assert_equals(poped.block, root_a) 31 | 32 | 33 | @with_setup(setup, tear_down) 34 | def test_push_get_purge_context(): 35 | root = Root() 36 | push_context(root) 37 | assert_equals(len(CONTEXTS), 1) 38 | assert_equals(get_context().block, root) 39 | root = Root() 40 | push_context(root) 41 | assert_equals(len(CONTEXTS), 2) 42 | assert_equals(get_context().block, root) 43 | 44 | purge_context() 45 | assert_equals(len(CONTEXTS), 0) 46 | 47 | 48 | @with_setup(setup, tear_down) 49 | def test_add_variables(): 50 | context = push_context(Root()) 51 | assert_equals(len(context.variables['index']), 0) 52 | assert_equals(len(context.variables['name']), 0) 53 | 54 | one_str_var = Variable('1') 55 | context.add_var('1', one_str_var) 56 | one_int_var = Variable(1) 57 | context.add_var(1, one_int_var) 58 | some_var = Variable('some') 59 | context.add_var('some', some_var) 60 | 61 | assert_equals(len(context.variables['index']), 1) 62 | assert_equals(context.variables['index'][1], one_int_var) 63 | assert_equals(len(context.variables['name']), 1) 64 | assert_equals(context.variables['name']['some'], some_var) 65 | context.clear_index_vars() 66 | assert_equals(len(context.variables['index']), 0) 67 | assert_equals(len(context.variables['name']), 1) 68 | assert_equals(context.variables['name']['some'], some_var) 69 | 70 | 71 | @with_setup(setup, tear_down) 72 | def test_get_variables(): 73 | context = push_context(Root()) 74 | assert_equals(len(context.variables['index']), 0) 75 | assert_equals(len(context.variables['name']), 0) 76 | 77 | one_var = Variable(1) 78 | context.add_var(1, one_var) 79 | some_var = Variable('some') 80 | context.add_var('some', some_var) 81 | 82 | assert_equals(context.get_var(1), one_var) 83 | assert_equals(context.get_var('some'), some_var) 84 | # Checks not existed variables, for now context may return None 85 | assert_equals(context.get_var(0), None) 86 | assert_equals(context.get_var('not_existed'), None) 87 | # Checks builtins variables 88 | assert_true(context.get_var('uri')) 89 | assert_true(context.get_var('document_uri')) 90 | assert_true(context.get_var('arg_asdsadasd')) 91 | assert_true(context.get_var('args')) 92 | 93 | 94 | @with_setup(setup, tear_down) 95 | def test_context_depend_variables(): 96 | push_context(Root()) 97 | assert_equals(len(get_context().variables['index']), 0) 98 | assert_equals(len(get_context().variables['name']), 0) 99 | 100 | get_context().add_var(1, Variable(1, value='one')) 101 | get_context().add_var('some', Variable('some', value='some')) 102 | 103 | assert_equals(get_context().get_var(1).value, 'one') 104 | assert_equals(get_context().get_var('some').value, 'some') 105 | 106 | # Checks top context variables are still exists 107 | push_context(Root()) 108 | assert_equals(get_context().get_var(1).value, 'one') 109 | assert_equals(get_context().get_var('some').value, 'some') 110 | 111 | # Checks variable overriding 112 | get_context().add_var('some', Variable('some', value='some_new')) 113 | get_context().add_var('foo', Variable('foo', value='foo')) 114 | assert_not_equals(get_context().get_var('some').value, 'some') 115 | assert_equals(get_context().get_var('some').value, 'some_new') 116 | assert_equals(get_context().get_var('foo').value, 'foo') 117 | assert_equals(get_context().get_var(1).value, 'one') 118 | 119 | # Checks variables after restore previous context 120 | pop_context() 121 | assert_not_equals(get_context().get_var('some').value, 'some_new') 122 | assert_equals(get_context().get_var('some').value, 'some') 123 | assert_equals(get_context().get_var('foo'), None) 124 | assert_equals(get_context().get_var(1).value, 'one') 125 | 126 | 127 | @with_setup(setup, tear_down) 128 | def test_push_failed_with_regexp_py35_gixy_10(): 129 | push_context(Root()) 130 | assert_equals(len(get_context().variables['index']), 0) 131 | assert_equals(len(get_context().variables['name']), 0) 132 | 133 | regexp = Regexp('^/some/(.*?)') 134 | for name, group in regexp.groups.items(): 135 | get_context().add_var(name, Variable(name=name, value=group)) 136 | 137 | push_context(Root()) 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GIXY 2 | ==== 3 | [![Mozilla Public License 2.0](https://img.shields.io/github/license/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/blob/master/LICENSE) 4 | [![Build Status](https://img.shields.io/travis/yandex/gixy.svg?style=flat-square)](https://travis-ci.org/yandex/gixy) 5 | [![Your feedback is greatly appreciated](https://img.shields.io/maintenance/yes/2019.svg?style=flat-square)](https://github.com/yandex/gixy/issues/new) 6 | [![GitHub issues](https://img.shields.io/github/issues/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/issues) 7 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/pulls) 8 | 9 | # Overview 10 | 11 | 12 | Gixy is a tool to analyze Nginx configuration. 13 | The main goal of Gixy is to prevent security misconfiguration and automate flaw detection. 14 | 15 | Currently supported Python versions are 2.7, 3.5, 3.6 and 3.7. 16 | 17 | Disclaimer: Gixy is well tested only on GNU/Linux, other OSs may have some issues. 18 | 19 | # What it can do 20 | Right now Gixy can find: 21 | * [[ssrf] Server Side Request Forgery](https://github.com/yandex/gixy/blob/master/docs/en/plugins/ssrf.md) 22 | * [[http_splitting] HTTP Splitting](https://github.com/yandex/gixy/blob/master/docs/en/plugins/httpsplitting.md) 23 | * [[origins] Problems with referrer/origin validation](https://github.com/yandex/gixy/blob/master/docs/en/plugins/origins.md) 24 | * [[add_header_redefinition] Redefining of response headers by "add_header" directive](https://github.com/yandex/gixy/blob/master/docs/en/plugins/addheaderredefinition.md) 25 | * [[host_spoofing] Request's Host header forgery](https://github.com/yandex/gixy/blob/master/docs/en/plugins/hostspoofing.md) 26 | * [[valid_referers] none in valid_referers](https://github.com/yandex/gixy/blob/master/docs/en/plugins/validreferers.md) 27 | * [[add_header_multiline] Multiline response headers](https://github.com/yandex/gixy/blob/master/docs/en/plugins/addheadermultiline.md) 28 | * [[alias_traversal] Path traversal via misconfigured alias](https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md) 29 | 30 | You can find things that Gixy is learning to detect at [Issues labeled with "new plugin"](https://github.com/yandex/gixy/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+plugin%22) 31 | 32 | # Installation 33 | Gixy is distributed on [PyPI](https://pypi.python.org/pypi/gixy). The best way to install it is with pip: 34 | ```bash 35 | pip install gixy 36 | ``` 37 | 38 | Run Gixy and check results: 39 | ```bash 40 | gixy 41 | ``` 42 | 43 | # Usage 44 | By default Gixy will try to analyze Nginx configuration placed in `/etc/nginx/nginx.conf`. 45 | 46 | But you can always specify needed path: 47 | ``` 48 | $ gixy /etc/nginx/nginx.conf 49 | 50 | ==================== Results =================== 51 | 52 | Problem: [http_splitting] Possible HTTP-Splitting vulnerability. 53 | Description: Using variables that can contain "\n" may lead to http injection. 54 | Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/httpsplitting.md 55 | Reason: At least variable "$action" can contain "\n" 56 | Pseudo config: 57 | include /etc/nginx/sites/default.conf; 58 | 59 | server { 60 | 61 | location ~ /v1/((?[^.]*)\.json)?$ { 62 | add_header X-Action $action; 63 | } 64 | } 65 | 66 | 67 | ==================== Summary =================== 68 | Total issues: 69 | Unspecified: 0 70 | Low: 0 71 | Medium: 0 72 | High: 1 73 | ``` 74 | 75 | Or skip some tests: 76 | ``` 77 | $ gixy --skips http_splitting /etc/nginx/nginx.conf 78 | 79 | ==================== Results =================== 80 | No issues found. 81 | 82 | ==================== Summary =================== 83 | Total issues: 84 | Unspecified: 0 85 | Low: 0 86 | Medium: 0 87 | High: 0 88 | ``` 89 | 90 | Or something else, you can find all other `gixy` arguments with the help command: `gixy --help` 91 | 92 | ## Docker usage 93 | 94 | Gixy is available as a Docker image [from the Docker hub](https://hub.docker.com/r/yandex/gixy/). To 95 | use it, mount the configuration that you want to analyse as a volume and provide the path to the 96 | configuration file when running the Gixy image. 97 | ``` 98 | $ docker run --rm -v `pwd`/nginx.conf:/etc/nginx/conf/nginx.conf yandex/gixy /etc/nginx/conf/nginx.conf 99 | ``` 100 | 101 | If you have an image that already contains your nginx configuration, you can share the configuration 102 | with the Gixy container as a volume. 103 | ``` 104 | $ docker run --rm --name nginx -d -v /etc/nginx 105 | nginx:alpinef68f2833e986ae69c0a5375f9980dc7a70684a6c233a9535c2a837189f14e905 106 | 107 | $ docker run --rm --volumes-from nginx yandex/gixy /etc/nginx/nginx.conf 108 | 109 | ==================== Results =================== 110 | No issues found. 111 | 112 | ==================== Summary =================== 113 | Total issues: 114 | Unspecified: 0 115 | Low: 0 116 | Medium: 0 117 | High: 0 118 | 119 | ``` 120 | 121 | # Contributing 122 | Contributions to Gixy are always welcome! You can help us in different ways: 123 | * Open an issue with suggestions for improvements and errors you're facing; 124 | * Fork this repository and submit a pull request; 125 | * Improve the documentation. 126 | 127 | Code guidelines: 128 | * Python code style should follow [pep8](https://www.python.org/dev/peps/pep-0008/) standards whenever possible; 129 | * Pull requests with new plugins must have unit tests for it. 130 | -------------------------------------------------------------------------------- /gixy/core/variable.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from gixy.core.regexp import Regexp 5 | from gixy.core.context import get_context 6 | 7 | LOG = logging.getLogger(__name__) 8 | # See ngx_http_script_compile in http/ngx_http_script.c 9 | EXTRACT_RE = re.compile(r'\$([1-9]|[a-z_][a-z0-9_]*|\{[a-z0-9_]+\})', re.IGNORECASE) 10 | 11 | 12 | def compile_script(script): 13 | """ 14 | Compile Nginx script to list of variables. 15 | Example: 16 | compile_script('http://$foo:$bar') -> 17 | [Variable('http://'), Variable($foo), Variable(':', Variable($bar). 18 | 19 | :param str script: Nginx scrip. 20 | :return Variable[]: list of variable. 21 | """ 22 | depends = [] 23 | context = get_context() 24 | for i, var in enumerate(EXTRACT_RE.split(str(script))): 25 | if i % 2: 26 | # Variable 27 | var = var.strip('{}\x20') 28 | var = context.get_var(var) 29 | if var: 30 | depends.append(var) 31 | elif var: 32 | # Literal 33 | depends.append(Variable(name=None, value=var, have_script=False)) 34 | return depends 35 | 36 | 37 | class Variable(object): 38 | def __init__(self, name, value=None, boundary=None, provider=None, have_script=True): 39 | """ 40 | Gixy Nginx variable class - parse and provide helpers to work with it. 41 | 42 | :param str|None name: variable name. 43 | :param str|Regexp value: variable value.. 44 | :param Regexp boundary: variable boundary set. 45 | :param Directive provider: directive that provide variable (e.g. if, location, rewrite, etc). 46 | :param bool have_script: may variable have nginx script or not (mostly used to indicate a string literal). 47 | """ 48 | 49 | self.name = name 50 | self.value = value 51 | self.regexp = None 52 | self.depends = None 53 | self.boundary = boundary 54 | self.provider = provider 55 | if isinstance(value, Regexp): 56 | self.regexp = value 57 | elif have_script: 58 | self.depends = compile_script(value) 59 | 60 | def can_contain(self, char): 61 | """ 62 | Checks if variable can contain the specified char. 63 | 64 | :param str char: character to test. 65 | :return: True if variable can contain the specified char, False otherwise. 66 | """ 67 | 68 | # First of all check boundary set 69 | if self.boundary and not self.boundary.can_contain(char): 70 | return False 71 | 72 | # Then regexp 73 | if self.regexp: 74 | return self.regexp.can_contain(char, skip_literal=True) 75 | 76 | # Then dependencies 77 | if self.depends: 78 | return any(dep.can_contain(char) for dep in self.depends) 79 | 80 | # Otherwise user can't control value of this variable 81 | return False 82 | 83 | def can_startswith(self, char): 84 | """ 85 | Checks if variable can starts with the specified char. 86 | 87 | :param str char: character to test. 88 | :return: True if variable can starts with the specified char, False otherwise. 89 | """ 90 | 91 | # First of all check boundary set 92 | if self.boundary and not self.boundary.can_startswith(char): 93 | return False 94 | 95 | # Then regexp 96 | if self.regexp: 97 | return self.regexp.can_startswith(char) 98 | 99 | # Then dependencies 100 | if self.depends: 101 | return self.depends[0].can_startswith(char) 102 | 103 | # Otherwise user can't control value of this variable 104 | return False 105 | 106 | def must_contain(self, char): 107 | """ 108 | Checks if variable MUST contain the specified char. 109 | 110 | :param str char: character to test. 111 | :return: True if variable must contain the specified char, False otherwise. 112 | """ 113 | 114 | # First of all check boundary set 115 | if self.boundary and self.boundary.must_contain(char): 116 | return True 117 | 118 | # Then regexp 119 | if self.regexp: 120 | return self.regexp.must_contain(char) 121 | 122 | # Then dependencies 123 | if self.depends: 124 | return any(dep.must_contain(char) for dep in self.depends) 125 | 126 | # Otherwise checks literal 127 | return self.value and char in self.value 128 | 129 | def must_startswith(self, char): 130 | """ 131 | Checks if variable MUST starts with the specified char. 132 | 133 | :param str char: character to test. 134 | :return: True if variable must starts with the specified char. 135 | """ 136 | 137 | # First of all check boundary set 138 | if self.boundary and self.boundary.must_startswith(char): 139 | return True 140 | 141 | # Then regexp 142 | if self.regexp: 143 | return self.regexp.must_startswith(char) 144 | 145 | # Then dependencies 146 | if self.depends: 147 | return self.depends[0].must_startswith(char) 148 | 149 | # Otherwise checks literal 150 | return self.value and self.value[0] == char 151 | 152 | @property 153 | def providers(self): 154 | """ 155 | Returns list of variable provides. 156 | 157 | :return Directive[]: providers. 158 | """ 159 | result = [] 160 | if self.provider: 161 | result.append(self.provider) 162 | if self.depends: 163 | for dep in self.depends: 164 | result += dep.providers 165 | return result 166 | -------------------------------------------------------------------------------- /gixy/parser/nginx_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import logging 4 | import fnmatch 5 | 6 | from pyparsing import ParseException 7 | from gixy.core.exceptions import InvalidConfiguration 8 | from gixy.parser import raw_parser 9 | from gixy.directives import block, directive 10 | from gixy.utils.text import to_native 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | 15 | class NginxParser(object): 16 | def __init__(self, cwd='', allow_includes=True): 17 | self.cwd = cwd 18 | self.configs = {} 19 | self.is_dump = False 20 | self.allow_includes = allow_includes 21 | self.directives = {} 22 | self.parser = raw_parser.RawParser() 23 | self._init_directives() 24 | 25 | def parse_file(self, path, root=None): 26 | LOG.debug("Parse file: {0}".format(path)) 27 | content = open(path).read() 28 | return self.parse(content=content, root=root, path_info=path) 29 | 30 | def parse(self, content, root=None, path_info=None): 31 | if not root: 32 | root = block.Root() 33 | try: 34 | parsed = self.parser.parse(content) 35 | except ParseException as e: 36 | error_msg = 'char {char} (line:{line}, col:{col})'.format(char=e.loc, line=e.lineno, col=e.col) 37 | if path_info: 38 | LOG.error('Failed to parse config "{file}": {error}'.format(file=path_info, error=error_msg)) 39 | else: 40 | LOG.error('Failed to parse config: {error}'.format(error=error_msg)) 41 | raise InvalidConfiguration(error_msg) 42 | 43 | if len(parsed) and parsed[0].getName() == 'file_delimiter': 44 | # Were parse nginx dump 45 | LOG.info('Switched to parse nginx configuration dump.') 46 | root_filename = self._prepare_dump(parsed) 47 | self.is_dump = True 48 | self.cwd = os.path.dirname(root_filename) 49 | parsed = self.configs[root_filename] 50 | 51 | self.parse_block(parsed, root) 52 | return root 53 | 54 | def parse_block(self, parsed_block, parent): 55 | for parsed in parsed_block: 56 | parsed_type = parsed.getName() 57 | parsed_name = parsed[0] 58 | parsed_args = parsed[1:] 59 | if parsed_type == 'include': 60 | # TODO: WTF?! 61 | self._resolve_include(parsed_args, parent) 62 | else: 63 | directive_inst = self.directive_factory(parsed_type, parsed_name, parsed_args) 64 | if directive_inst: 65 | parent.append(directive_inst) 66 | 67 | def directive_factory(self, parsed_type, parsed_name, parsed_args): 68 | klass = self._get_directive_class(parsed_type, parsed_name) 69 | if not klass: 70 | return None 71 | 72 | if klass.is_block: 73 | args = [to_native(v).strip() for v in parsed_args[0]] 74 | children = parsed_args[1] 75 | 76 | inst = klass(parsed_name, args) 77 | self.parse_block(children, inst) 78 | return inst 79 | else: 80 | args = [to_native(v).strip() for v in parsed_args] 81 | return klass(parsed_name, args) 82 | 83 | def _get_directive_class(self, parsed_type, parsed_name): 84 | if parsed_type in self.directives and parsed_name in self.directives[parsed_type]: 85 | return self.directives[parsed_type][parsed_name] 86 | elif parsed_type == 'block': 87 | return block.Block 88 | elif parsed_type == 'directive': 89 | return directive.Directive 90 | elif parsed_type == 'unparsed_block': 91 | LOG.warning('Skip unparseable block: "%s"', parsed_name) 92 | return None 93 | else: 94 | return None 95 | 96 | def _init_directives(self): 97 | self.directives['block'] = block.get_overrides() 98 | self.directives['directive'] = directive.get_overrides() 99 | 100 | def _resolve_include(self, args, parent): 101 | pattern = args[0] 102 | # TODO(buglloc): maybe file providers? 103 | if self.is_dump: 104 | return self._resolve_dump_include(pattern=pattern, parent=parent) 105 | if not self.allow_includes: 106 | LOG.debug('Includes are disallowed, skip: {0}'.format(pattern)) 107 | return 108 | 109 | return self._resolve_file_include(pattern=pattern, parent=parent) 110 | 111 | def _resolve_file_include(self, pattern, parent): 112 | path = os.path.join(self.cwd, pattern) 113 | exists = False 114 | for file_path in glob.iglob(path): 115 | if not os.path.exists(file_path): 116 | continue 117 | exists = True 118 | include = block.IncludeBlock('include', [file_path]) 119 | parent.append(include) 120 | self.parse_file(file_path, include) 121 | 122 | if not exists: 123 | LOG.warning('File not found: {0}'.format(path)) 124 | 125 | def _resolve_dump_include(self, pattern, parent): 126 | path = os.path.join(self.cwd, pattern) 127 | founded = False 128 | for file_path, parsed in self.configs.items(): 129 | if fnmatch.fnmatch(file_path, path): 130 | founded = True 131 | include = block.IncludeBlock('include', [file_path]) 132 | parent.append(include) 133 | self.parse_block(parsed, include) 134 | 135 | if not founded: 136 | LOG.warning("File not found: {0}".format(path)) 137 | 138 | def _prepare_dump(self, parsed_block): 139 | filename = '' 140 | root_filename = '' 141 | for parsed in parsed_block: 142 | if parsed.getName() == 'file_delimiter': 143 | if not filename: 144 | root_filename = parsed[0] 145 | filename = parsed[0] 146 | self.configs[filename] = [] 147 | continue 148 | self.configs[filename].append(parsed) 149 | return root_filename 150 | -------------------------------------------------------------------------------- /gixy/directives/block.py: -------------------------------------------------------------------------------- 1 | from cached_property import cached_property 2 | 3 | from gixy.directives.directive import Directive 4 | from gixy.core.variable import Variable 5 | from gixy.core.regexp import Regexp 6 | 7 | 8 | def get_overrides(): 9 | result = {} 10 | for klass in Block.__subclasses__(): 11 | if not klass.nginx_name: 12 | continue 13 | 14 | if not klass.__name__.endswith('Block'): 15 | continue 16 | 17 | result[klass.nginx_name] = klass 18 | return result 19 | 20 | 21 | class Block(Directive): 22 | nginx_name = None 23 | is_block = True 24 | self_context = True 25 | 26 | def __init__(self, name, args): 27 | super(Block, self).__init__(name, args) 28 | self.children = [] 29 | 30 | def some(self, name, flat=True): 31 | for child in self.children: 32 | if child.name == name: 33 | return child 34 | if flat and child.is_block and not child.self_context: 35 | result = child.some(name, flat=flat) 36 | if result: 37 | return result 38 | return None 39 | 40 | def find(self, name, flat=False): 41 | result = [] 42 | for child in self.children: 43 | if child.name == name: 44 | result.append(child) 45 | if flat and child.is_block and not child.self_context: 46 | result += child.find(name) 47 | return result 48 | 49 | def find_recursive(self, name): 50 | result = [] 51 | for child in self.children: 52 | if child.name == name: 53 | result.append(child) 54 | if child.is_block: 55 | result += child.find_recursive(name) 56 | return result 57 | 58 | def append(self, directive): 59 | directive.set_parent(self) 60 | self.children.append(directive) 61 | 62 | def __str__(self): 63 | return '{name} {args} {{'.format(name=self.name, args=' '.join(self.args)) 64 | 65 | 66 | class Root(Block): 67 | nginx_name = None 68 | 69 | def __init__(self): 70 | super(Root, self).__init__(None, []) 71 | 72 | 73 | class HttpBlock(Block): 74 | nginx_name = 'http' 75 | 76 | def __init__(self, name, args): 77 | super(HttpBlock, self).__init__(name, args) 78 | 79 | 80 | class ServerBlock(Block): 81 | nginx_name = 'server' 82 | 83 | def __init__(self, name, args): 84 | super(ServerBlock, self).__init__(name, args) 85 | 86 | def get_names(self): 87 | return self.find('server_name') 88 | 89 | def __str__(self): 90 | server_names = [str(sn) for sn in self.find('server_name')] 91 | if server_names: 92 | return 'server {{\n{0}'.format('\n'.join(server_names[:2])) 93 | return 'server {' 94 | 95 | 96 | class LocationBlock(Block): 97 | nginx_name = 'location' 98 | provide_variables = True 99 | 100 | def __init__(self, name, args): 101 | super(LocationBlock, self).__init__(name, args) 102 | if len(args) == 2: 103 | self.modifier, self.path = args 104 | else: 105 | self.modifier = None 106 | self.path = args[0] 107 | 108 | @property 109 | def is_internal(self): 110 | return self.some('internal') is not None 111 | 112 | @cached_property 113 | def variables(self): 114 | if not self.modifier or self.modifier not in ('~', '~*'): 115 | return [] 116 | 117 | regexp = Regexp(self.path, case_sensitive=self.modifier == '~') 118 | result = [] 119 | for name, group in regexp.groups.items(): 120 | result.append(Variable(name=name, value=group, boundary=None, provider=self)) 121 | return result 122 | 123 | 124 | class IfBlock(Block): 125 | nginx_name = 'if' 126 | self_context = False 127 | 128 | def __init__(self, name, args): 129 | super(IfBlock, self).__init__(name, args) 130 | self.operand = None 131 | self.value = None 132 | self.variable = None 133 | 134 | if len(args) == 1: 135 | # if ($slow) 136 | self.variable = args[0] 137 | elif len(args) == 2: 138 | # if (!-e $foo) 139 | self.operand, self.value = args 140 | elif len(args) == 3: 141 | # if ($request_method = POST) 142 | self.variable, self.operand, self.value = args 143 | else: 144 | raise Exception('Unknown "if" definition, args: {0!r}'.format(args)) 145 | 146 | def __str__(self): 147 | return '{name} ({args}) {{'.format(name=self.name, args=' '.join(self.args)) 148 | 149 | 150 | class IncludeBlock(Block): 151 | nginx_name = 'include' 152 | self_context = False 153 | 154 | def __init__(self, name, args): 155 | super(IncludeBlock, self).__init__(name, args) 156 | self.file_path = args[0] 157 | 158 | def __str__(self): 159 | return 'include {0};'.format(self.file_path) 160 | 161 | 162 | class MapBlock(Block): 163 | nginx_name = 'map' 164 | self_context = False 165 | provide_variables = True 166 | 167 | def __init__(self, name, args): 168 | super(MapBlock, self).__init__(name, args) 169 | self.source = args[0] 170 | self.variable = args[1].strip('$') 171 | 172 | @cached_property 173 | def variables(self): 174 | # TODO(buglloc): Finish him! 175 | return [Variable(name=self.variable, value='', boundary=None, provider=self, have_script=False)] 176 | 177 | 178 | class GeoBlock(Block): 179 | nginx_name = 'geo' 180 | self_context = False 181 | provide_variables = True 182 | 183 | def __init__(self, name, args): 184 | super(GeoBlock, self).__init__(name, args) 185 | if len(args) == 1: # geo uses $remote_addr as default source of the value 186 | source = '$remote_addr' 187 | variable = args[0].strip('$') 188 | else: 189 | source = args[0] 190 | variable = args[1].strip('$') 191 | self.source = source 192 | self.variable = variable 193 | 194 | @cached_property 195 | def variables(self): 196 | # TODO(buglloc): Finish him! -- same as in MapBlock 197 | return [Variable(name=self.variable, value='', boundary=None, provider=self, have_script=False)] 198 | -------------------------------------------------------------------------------- /README.RU.md: -------------------------------------------------------------------------------- 1 | GIXY 2 | ==== 3 | [![Mozilla Public License 2.0](https://img.shields.io/github/license/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/blob/master/LICENSE) 4 | [![Build Status](https://img.shields.io/travis/yandex/gixy.svg?style=flat-square)](https://travis-ci.org/yandex/gixy) 5 | [![Your feedback is greatly appreciated](https://img.shields.io/maintenance/yes/2018.svg?style=flat-square)](https://github.com/yandex/gixy/issues/new) 6 | [![GitHub issues](https://img.shields.io/github/issues/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/issues) 7 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/yandex/gixy.svg?style=flat-square)](https://github.com/yandex/gixy/pulls) 8 | 9 | # Overview 10 | 11 | 12 | Gixy — это утилита для анализа конфигурации Nginx. 13 | Большей частью служит для обнаружения проблем безопасности, но может искать и иные ошибки. 14 | 15 | Официально поддерживаются версии Python 2.7, 3.5, 3.6 и 3.7 16 | 17 |   18 | # Что умеет 19 | На текущий момент Gixy способна обнаружить: 20 | * [[ssrf] Server Side Request Forgery](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/ssrf.md) 21 | * [[http_splitting] HTTP Splitting](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/httpsplitting.md) 22 | * [[origins] Проблемы валидации referrer/origin](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/origins.md) 23 | * [[add_header_redefinition] Переопределение "вышестоящих" заголовков ответа директивой "add_header"](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/addheaderredefinition.md) 24 | * [[host_spoofing] Подделка заголовка запроса Host](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/hostspoofing.md) 25 | * [[valid_referers] none in valid_referers](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/validreferers.md) 26 | * [[add_header_multiline] Многострочные заголовоки ответа](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/addheadermultiline.md) 27 | * [[alias_traversal] Path traversal при использовании alias](https://github.com/yandex/gixy/blob/master/docs/ru/plugins/aliastraversal.md) 28 | 29 | Проблемы, которым Gixy только учится можно найти в [Issues с меткой "new plugin"](https://github.com/yandex/gixy/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+plugin%22) 30 | 31 | # Установка 32 | Наиболее простой способ установки Gixy - воспользоваться pip для установки из [PyPI](https://pypi.python.org/pypi/gixy): 33 | ```bash 34 | pip install gixy 35 | ``` 36 | 37 | # Использование 38 | После установки должна стать доступна консольная утилита `gixy`. 39 | По умолчанию Gixy ищет конфигурацию по стандартному пути `/etc/nginx/nginx.conf`, однако вы можете указать специфичное расположение: 40 | ``` 41 | $ gixy /etc/nginx/nginx.conf 42 | 43 | ==================== Results =================== 44 | 45 | Problem: [http_splitting] Possible HTTP-Splitting vulnerability. 46 | Description: Using variables that can contain "\n" may lead to http injection. 47 | Additional info: https://github.com/yandex/gixy/wiki/ru/httpsplitting 48 | Reason: At least variable "$action" can contain "\n" 49 | Pseudo config: 50 | include /etc/nginx/sites/default.conf; 51 | 52 | server { 53 | 54 | location ~ /v1/((?[^.]*)\.json)?$ { 55 | add_header X-Action $action; 56 | } 57 | } 58 | 59 | 60 | ==================== Summary =================== 61 | Total issues: 62 | Unspecified: 0 63 | Low: 0 64 | Medium: 0 65 | High: 1 66 | ``` 67 | 68 | Gixy умеет обрабатывать директиву `include` и попробует максимально корректно обработать все зависимости, если что-то пошло не так можно попробовать запустить `gixy` с флагом `-d` для вывода дополнительной информации. 69 | Все доступные опции: 70 | ``` 71 | $ gixy -h 72 | usage: gixy [-h] [-c CONFIG_FILE] [--write-config CONFIG_OUTPUT_PATH] 73 | [-v] [-l] [-f {console,text,json}] [-o OUTPUT_FILE] [-d] 74 | [--tests TESTS] [--skips SKIPS] [--disable-includes] 75 | [--origins-domains domains] 76 | [--origins-https-only https_only] 77 | [--add-header-redefinition-headers headers] 78 | [nginx.conf] 79 | 80 | Gixy - a Nginx configuration [sec]analyzer 81 | 82 | positional arguments: 83 | nginx.conf Path to nginx.conf, e.g. /etc/nginx/nginx.conf 84 | 85 | optional arguments: 86 | -h, --help show this help message and exit 87 | -c CONFIG_FILE, --config CONFIG_FILE 88 | config file path 89 | --write-config CONFIG_OUTPUT_PATH 90 | takes the current command line args and writes them 91 | out to a config file at the given path, then exits 92 | -v, --version show program's version number and exit 93 | -l, --level Report issues of a given severity level or higher (-l 94 | for LOW, -ll for MEDIUM, -lll for HIGH) 95 | -f {console,text,json}, --format {console,text,json} 96 | Specify output format 97 | -o OUTPUT_FILE, --output OUTPUT_FILE 98 | Write report to file 99 | -d, --debug Turn on debug mode 100 | --tests TESTS Comma-separated list of tests to run 101 | --skips SKIPS Comma-separated list of tests to skip 102 | --disable-includes Disable "include" directive processing 103 | 104 | plugins options: 105 | --origins-domains domains 106 | Default: * 107 | --origins-https-only https_only 108 | Default: False 109 | --add-header-redefinition-headers headers 110 | Default: content-security-policy,x-xss- 111 | protection,x-frame-options,x-content-type- 112 | options,strict-transport-security,cache-control 113 | 114 | 115 | available plugins: 116 | host_spoofing 117 | add_header_multiline 118 | http_splitting 119 | valid_referers 120 | origins 121 | add_header_redefinition 122 | ssrf 123 | ``` 124 | 125 | # Contributing 126 | Contributions to Gixy are always welcome! You can help us in different ways: 127 | * Open an issue with suggestions for improvements and errors you're facing; 128 | * Fork this repository and submit a pull request; 129 | * Improve the documentation. 130 | 131 | Code guidelines: 132 | * Python code style should follow [pep8](https://www.python.org/dev/peps/pep-0008/) standards whenever possible; 133 | * Pull requests with new plugins must have unit tests for it. 134 | -------------------------------------------------------------------------------- /gixy/parser/raw_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import codecs 3 | import six 4 | from cached_property import cached_property 5 | 6 | from pyparsing import ( 7 | Literal, Suppress, White, Word, alphanums, Forward, Group, Optional, Combine, 8 | Keyword, OneOrMore, ZeroOrMore, Regex, QuotedString, nestedExpr, ParseResults) 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class NginxQuotedString(QuotedString): 14 | def __init__(self, quoteChar): 15 | super(NginxQuotedString, self).__init__(quoteChar, escChar='\\', multiline=True) 16 | # Nginx parse quoted values in special manner: 17 | # '^https?:\/\/yandex\.ru\/\00\'\"' -> ^https?:\/\/yandex\.ru\/\00'" 18 | # TODO(buglloc): research and find another special characters! 19 | 20 | self.escCharReplacePattern = '\\\\(\'|")' 21 | 22 | 23 | class RawParser(object): 24 | """ 25 | A class that parses nginx configuration with pyparsing 26 | """ 27 | 28 | def parse(self, data): 29 | """ 30 | Returns the parsed tree. 31 | """ 32 | if isinstance(data, six.binary_type): 33 | if data[:3] == codecs.BOM_UTF8: 34 | encoding = 'utf-8-sig' 35 | else: 36 | encoding = 'latin1' 37 | content = data.decode(encoding).strip() 38 | else: 39 | content = data.strip() 40 | 41 | if not content: 42 | return ParseResults() 43 | 44 | return self.script.parseString(content, parseAll=True) 45 | 46 | @cached_property 47 | def script(self): 48 | # constants 49 | left_bracket = Suppress("{") 50 | right_bracket = Suppress("}") 51 | semicolon = Suppress(";") 52 | space = White().suppress() 53 | keyword = Word(alphanums + ".+-_/") 54 | path = Word(alphanums + ".-_/") 55 | variable = Word("$_-" + alphanums) 56 | value_wq = Regex(r'(?:\([^\s;]*\)|\$\{\w+\}|[^\s;(){}])+') 57 | value_sq = NginxQuotedString(quoteChar="'") 58 | value_dq = NginxQuotedString(quoteChar='"') 59 | value = (value_dq | value_sq | value_wq) 60 | # modifier for location uri [ = | ~ | ~* | ^~ ] 61 | location_modifier = ( 62 | Keyword("=") | 63 | Keyword("~*") | Keyword("~") | 64 | Keyword("^~")) 65 | # modifier for if statement 66 | if_modifier = Combine(Optional("!") + ( 67 | Keyword("=") | 68 | Keyword("~*") | Keyword("~") | 69 | (Literal("-") + (Literal("f") | Literal("d") | Literal("e") | Literal("x"))))) 70 | # This ugly workaround needed to parse unquoted regex with nested parentheses 71 | # so we capture all content between parentheses and then parse it :( 72 | # TODO(buglloc): may be use something better? 73 | condition_body = ( 74 | (if_modifier + Optional(space) + value) | 75 | (variable + Optional(space + if_modifier + Optional(space) + value)) 76 | ) 77 | condition = Regex(r'\((?:[^()\n\r\\]|(?:\(.*\))|(?:\\.))+?\)')\ 78 | .setParseAction(lambda s, l, t: condition_body.parseString(t[0][1:-1])) 79 | 80 | # rules 81 | include = ( 82 | Keyword("include") + 83 | space + 84 | value + 85 | semicolon 86 | )("include") 87 | 88 | directive = ( 89 | keyword + 90 | ZeroOrMore(space + value) + 91 | semicolon 92 | )("directive") 93 | 94 | file_delimiter = ( 95 | Suppress("# configuration file ") + 96 | path + 97 | Suppress(":") 98 | )("file_delimiter") 99 | 100 | comment = ( 101 | Regex(r"#.*") 102 | )("comment").setParseAction(_fix_comment) 103 | 104 | hash_value = Group( 105 | value + 106 | ZeroOrMore(space + value) + 107 | semicolon 108 | )("hash_value") 109 | 110 | generic_block = Forward() 111 | if_block = Forward() 112 | location_block = Forward() 113 | hash_block = Forward() 114 | unparsed_block = Forward() 115 | 116 | sub_block = OneOrMore(Group(if_block | 117 | location_block | 118 | hash_block | 119 | generic_block | 120 | include | 121 | directive | 122 | file_delimiter | 123 | comment | 124 | unparsed_block)) 125 | 126 | if_block << ( 127 | Keyword("if") + 128 | Group(condition) + 129 | Suppress(Optional(comment)) + 130 | Group( 131 | left_bracket + 132 | Optional(sub_block) + 133 | right_bracket) 134 | )("block") 135 | 136 | location_block << ( 137 | Keyword("location") + 138 | Group( 139 | Optional(space + location_modifier) + 140 | Optional(space) + value) + 141 | Suppress(Optional(comment)) + 142 | Group( 143 | left_bracket + 144 | Optional(sub_block) + 145 | right_bracket) 146 | )("block") 147 | 148 | hash_block << ( 149 | keyword + 150 | Group(OneOrMore(space + value)) + 151 | Group( 152 | left_bracket + 153 | Optional(OneOrMore(hash_value)) + 154 | right_bracket) 155 | )("block") 156 | 157 | generic_block << ( 158 | keyword + 159 | Group(ZeroOrMore(space + value)) + 160 | Suppress(Optional(comment)) + 161 | Group( 162 | left_bracket + 163 | Optional(sub_block) + 164 | right_bracket) 165 | )("block") 166 | 167 | unparsed_block << ( 168 | keyword + 169 | Group(ZeroOrMore(space + value)) + 170 | nestedExpr(opener="{", closer="}") 171 | )("unparsed_block") 172 | 173 | return sub_block 174 | 175 | 176 | def _fix_comment(string, location, tokens): 177 | """ 178 | Returns "cleared" comment text 179 | 180 | :param string: original parse string 181 | :param location: location in the string where matching started 182 | :param tokens: list of the matched tokens, packaged as a ParseResults_ object 183 | :return: list of the cleared comment tokens 184 | """ 185 | 186 | comment = tokens[0][1:].strip() 187 | return [comment] 188 | -------------------------------------------------------------------------------- /gixy/core/sre_parse/sre_constants.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | # 4 | # Secret Labs' Regular Expression Engine 5 | # 6 | # various symbols used by the regular expression engine. 7 | # run this script to update the _sre include files! 8 | # 9 | # Copyright (c) 1998-2001 by Secret Labs AB. All rights reserved. 10 | # 11 | # See the sre.py file for information on usage and redistribution. 12 | # 13 | 14 | """Internal support module for sre""" 15 | 16 | # update when constants are added or removed 17 | 18 | MAGIC = 20031017 19 | 20 | try: 21 | from _sre import MAXREPEAT 22 | except ImportError: 23 | import _sre 24 | 25 | MAXREPEAT = _sre.MAXREPEAT = 65535 26 | 27 | 28 | # SRE standard exception (access as sre.error) 29 | # should this really be here? 30 | 31 | class error(Exception): 32 | pass 33 | 34 | 35 | # operators 36 | 37 | FAILURE = "failure" 38 | SUCCESS = "success" 39 | 40 | ANY = "any" 41 | ANY_ALL = "any_all" 42 | ASSERT = "assert" 43 | ASSERT_NOT = "assert_not" 44 | AT = "at" 45 | BIGCHARSET = "bigcharset" 46 | BRANCH = "branch" 47 | CALL = "call" 48 | CATEGORY = "category" 49 | CHARSET = "charset" 50 | GROUPREF = "groupref" 51 | GROUPREF_IGNORE = "groupref_ignore" 52 | GROUPREF_EXISTS = "groupref_exists" 53 | IN = "in" 54 | IN_IGNORE = "in_ignore" 55 | INFO = "info" 56 | JUMP = "jump" 57 | LITERAL = "literal" 58 | LITERAL_IGNORE = "literal_ignore" 59 | MARK = "mark" 60 | MAX_REPEAT = "max_repeat" 61 | MAX_UNTIL = "max_until" 62 | MIN_REPEAT = "min_repeat" 63 | MIN_UNTIL = "min_until" 64 | NEGATE = "negate" 65 | NOT_LITERAL = "not_literal" 66 | NOT_LITERAL_IGNORE = "not_literal_ignore" 67 | RANGE = "range" 68 | REPEAT = "repeat" 69 | REPEAT_ONE = "repeat_one" 70 | SUBPATTERN = "subpattern" 71 | MIN_REPEAT_ONE = "min_repeat_one" 72 | 73 | # positions 74 | AT_BEGINNING = "at_beginning" 75 | AT_BEGINNING_LINE = "at_beginning_line" 76 | AT_BEGINNING_STRING = "at_beginning_string" 77 | AT_BOUNDARY = "at_boundary" 78 | AT_NON_BOUNDARY = "at_non_boundary" 79 | AT_END = "at_end" 80 | AT_END_LINE = "at_end_line" 81 | AT_END_STRING = "at_end_string" 82 | AT_LOC_BOUNDARY = "at_loc_boundary" 83 | AT_LOC_NON_BOUNDARY = "at_loc_non_boundary" 84 | AT_UNI_BOUNDARY = "at_uni_boundary" 85 | AT_UNI_NON_BOUNDARY = "at_uni_non_boundary" 86 | 87 | # categories 88 | CATEGORY_DIGIT = "category_digit" 89 | CATEGORY_NOT_DIGIT = "category_not_digit" 90 | CATEGORY_SPACE = "category_space" 91 | CATEGORY_NOT_SPACE = "category_not_space" 92 | CATEGORY_WORD = "category_word" 93 | CATEGORY_NOT_WORD = "category_not_word" 94 | CATEGORY_LINEBREAK = "category_linebreak" 95 | CATEGORY_NOT_LINEBREAK = "category_not_linebreak" 96 | CATEGORY_LOC_WORD = "category_loc_word" 97 | CATEGORY_LOC_NOT_WORD = "category_loc_not_word" 98 | CATEGORY_UNI_DIGIT = "category_uni_digit" 99 | CATEGORY_UNI_NOT_DIGIT = "category_uni_not_digit" 100 | CATEGORY_UNI_SPACE = "category_uni_space" 101 | CATEGORY_UNI_NOT_SPACE = "category_uni_not_space" 102 | CATEGORY_UNI_WORD = "category_uni_word" 103 | CATEGORY_UNI_NOT_WORD = "category_uni_not_word" 104 | CATEGORY_UNI_LINEBREAK = "category_uni_linebreak" 105 | CATEGORY_UNI_NOT_LINEBREAK = "category_uni_not_linebreak" 106 | 107 | OPCODES = [ 108 | 109 | # failure=0 success=1 (just because it looks better that way :-) 110 | FAILURE, SUCCESS, 111 | 112 | ANY, ANY_ALL, 113 | ASSERT, ASSERT_NOT, 114 | AT, 115 | BRANCH, 116 | CALL, 117 | CATEGORY, 118 | CHARSET, BIGCHARSET, 119 | GROUPREF, GROUPREF_EXISTS, GROUPREF_IGNORE, 120 | IN, IN_IGNORE, 121 | INFO, 122 | JUMP, 123 | LITERAL, LITERAL_IGNORE, 124 | MARK, 125 | MAX_UNTIL, 126 | MIN_UNTIL, 127 | NOT_LITERAL, NOT_LITERAL_IGNORE, 128 | NEGATE, 129 | RANGE, 130 | REPEAT, 131 | REPEAT_ONE, 132 | SUBPATTERN, 133 | MIN_REPEAT_ONE 134 | 135 | ] 136 | 137 | ATCODES = [ 138 | AT_BEGINNING, AT_BEGINNING_LINE, AT_BEGINNING_STRING, AT_BOUNDARY, 139 | AT_NON_BOUNDARY, AT_END, AT_END_LINE, AT_END_STRING, 140 | AT_LOC_BOUNDARY, AT_LOC_NON_BOUNDARY, AT_UNI_BOUNDARY, 141 | AT_UNI_NON_BOUNDARY 142 | ] 143 | 144 | CHCODES = [ 145 | CATEGORY_DIGIT, CATEGORY_NOT_DIGIT, CATEGORY_SPACE, 146 | CATEGORY_NOT_SPACE, CATEGORY_WORD, CATEGORY_NOT_WORD, 147 | CATEGORY_LINEBREAK, CATEGORY_NOT_LINEBREAK, CATEGORY_LOC_WORD, 148 | CATEGORY_LOC_NOT_WORD, CATEGORY_UNI_DIGIT, CATEGORY_UNI_NOT_DIGIT, 149 | CATEGORY_UNI_SPACE, CATEGORY_UNI_NOT_SPACE, CATEGORY_UNI_WORD, 150 | CATEGORY_UNI_NOT_WORD, CATEGORY_UNI_LINEBREAK, 151 | CATEGORY_UNI_NOT_LINEBREAK 152 | ] 153 | 154 | 155 | def makedict(list): 156 | d = {} 157 | i = 0 158 | for item in list: 159 | d[item] = i 160 | i = i + 1 161 | return d 162 | 163 | 164 | OPCODES = makedict(OPCODES) 165 | ATCODES = makedict(ATCODES) 166 | CHCODES = makedict(CHCODES) 167 | 168 | # replacement operations for "ignore case" mode 169 | OP_IGNORE = { 170 | GROUPREF: GROUPREF_IGNORE, 171 | IN: IN_IGNORE, 172 | LITERAL: LITERAL_IGNORE, 173 | NOT_LITERAL: NOT_LITERAL_IGNORE 174 | } 175 | 176 | AT_MULTILINE = { 177 | AT_BEGINNING: AT_BEGINNING_LINE, 178 | AT_END: AT_END_LINE 179 | } 180 | 181 | AT_LOCALE = { 182 | AT_BOUNDARY: AT_LOC_BOUNDARY, 183 | AT_NON_BOUNDARY: AT_LOC_NON_BOUNDARY 184 | } 185 | 186 | AT_UNICODE = { 187 | AT_BOUNDARY: AT_UNI_BOUNDARY, 188 | AT_NON_BOUNDARY: AT_UNI_NON_BOUNDARY 189 | } 190 | 191 | CH_LOCALE = { 192 | CATEGORY_DIGIT: CATEGORY_DIGIT, 193 | CATEGORY_NOT_DIGIT: CATEGORY_NOT_DIGIT, 194 | CATEGORY_SPACE: CATEGORY_SPACE, 195 | CATEGORY_NOT_SPACE: CATEGORY_NOT_SPACE, 196 | CATEGORY_WORD: CATEGORY_LOC_WORD, 197 | CATEGORY_NOT_WORD: CATEGORY_LOC_NOT_WORD, 198 | CATEGORY_LINEBREAK: CATEGORY_LINEBREAK, 199 | CATEGORY_NOT_LINEBREAK: CATEGORY_NOT_LINEBREAK 200 | } 201 | 202 | CH_UNICODE = { 203 | CATEGORY_DIGIT: CATEGORY_UNI_DIGIT, 204 | CATEGORY_NOT_DIGIT: CATEGORY_UNI_NOT_DIGIT, 205 | CATEGORY_SPACE: CATEGORY_UNI_SPACE, 206 | CATEGORY_NOT_SPACE: CATEGORY_UNI_NOT_SPACE, 207 | CATEGORY_WORD: CATEGORY_UNI_WORD, 208 | CATEGORY_NOT_WORD: CATEGORY_UNI_NOT_WORD, 209 | CATEGORY_LINEBREAK: CATEGORY_UNI_LINEBREAK, 210 | CATEGORY_NOT_LINEBREAK: CATEGORY_UNI_NOT_LINEBREAK 211 | } 212 | 213 | # flags 214 | SRE_FLAG_TEMPLATE = 1 # template mode (disable backtracking) 215 | SRE_FLAG_IGNORECASE = 2 # case insensitive 216 | SRE_FLAG_LOCALE = 4 # honour system locale 217 | SRE_FLAG_MULTILINE = 8 # treat target as multiline string 218 | SRE_FLAG_DOTALL = 16 # treat target as a single string 219 | SRE_FLAG_UNICODE = 32 # use unicode locale 220 | SRE_FLAG_VERBOSE = 64 # ignore whitespace and comments 221 | SRE_FLAG_DEBUG = 128 # debugging 222 | 223 | # flags for INFO primitive 224 | SRE_INFO_PREFIX = 1 # has prefix 225 | SRE_INFO_LITERAL = 2 # entire pattern is literal (given by prefix) 226 | SRE_INFO_CHARSET = 4 # pattern starts with character from given set 227 | -------------------------------------------------------------------------------- /gixy/cli/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | import copy 5 | 6 | import gixy 7 | from gixy.core.manager import Manager as Gixy 8 | from gixy.formatters import get_all as formatters 9 | from gixy.core.plugins_manager import PluginsManager 10 | from gixy.core.config import Config 11 | from gixy.cli.argparser import create_parser 12 | from gixy.core.exceptions import InvalidConfiguration 13 | 14 | LOG = logging.getLogger() 15 | 16 | 17 | def _init_logger(debug=False): 18 | LOG.handlers = [] 19 | log_level = logging.DEBUG if debug else logging.INFO 20 | 21 | LOG.setLevel(log_level) 22 | handler = logging.StreamHandler(sys.stderr) 23 | handler.setFormatter(logging.Formatter('[%(module)s]\t%(levelname)s\t%(message)s')) 24 | LOG.addHandler(handler) 25 | LOG.debug("logging initialized") 26 | 27 | 28 | def _create_plugin_help(option): 29 | if isinstance(option, (tuple, list, set)): 30 | default = ','.join(list(option)) 31 | else: 32 | default = str(option) 33 | 34 | return 'Default: {0}'.format(default) 35 | 36 | 37 | def _get_cli_parser(): 38 | parser = create_parser() 39 | parser.add_argument('nginx_files', nargs='*', type=str, default=['/etc/nginx/nginx.conf'], metavar='nginx.conf', 40 | help='Path to nginx.conf, e.g. /etc/nginx/nginx.conf') 41 | 42 | parser.add_argument( 43 | '-v', '--version', action='version', 44 | version='Gixy v{0}'.format(gixy.version)) 45 | 46 | parser.add_argument( 47 | '-l', '--level', dest='level', action='count', default=0, 48 | help='Report issues of a given severity level or higher (-l for LOW, -ll for MEDIUM, -lll for HIGH)') 49 | 50 | default_formatter = 'console' if sys.stdout.isatty() else 'text' 51 | available_formatters = formatters().keys() 52 | parser.add_argument( 53 | '-f', '--format', dest='output_format', choices=available_formatters, default=default_formatter, 54 | type=str, help='Specify output format') 55 | 56 | parser.add_argument( 57 | '-o', '--output', dest='output_file', type=str, 58 | help='Write report to file') 59 | 60 | parser.add_argument( 61 | '-d', '--debug', dest='debug', action='store_true', default=False, 62 | help='Turn on debug mode') 63 | 64 | parser.add_argument( 65 | '--tests', dest='tests', type=str, 66 | help='Comma-separated list of tests to run') 67 | 68 | parser.add_argument( 69 | '--skips', dest='skips', type=str, 70 | help='Comma-separated list of tests to skip') 71 | 72 | parser.add_argument( 73 | '--disable-includes', dest='disable_includes', action='store_true', default=False, 74 | help='Disable "include" directive processing') 75 | 76 | group = parser.add_argument_group('plugins options') 77 | for plugin_cls in PluginsManager().plugins_classes: 78 | name = plugin_cls.__name__ 79 | if not plugin_cls.options: 80 | continue 81 | 82 | options = copy.deepcopy(plugin_cls.options) 83 | for opt_key, opt_val in options.items(): 84 | option_name = '--{plugin}-{key}'.format(plugin=name, key=opt_key).replace('_', '-') 85 | dst_name = '{plugin}:{key}'.format(plugin=name, key=opt_key) 86 | opt_type = str if isinstance(opt_val, (tuple, list, set)) else type(opt_val) 87 | group.add_argument( 88 | option_name, metavar=opt_key, dest=dst_name, type=opt_type, 89 | help=_create_plugin_help(opt_val) 90 | ) 91 | 92 | return parser 93 | 94 | 95 | def main(): 96 | parser = _get_cli_parser() 97 | args = parser.parse_args() 98 | _init_logger(args.debug) 99 | 100 | if len(args.nginx_files) == 1 and args.nginx_files[0] != '-': 101 | path = os.path.expanduser(args.nginx_files[0]) 102 | if not os.path.exists(path): 103 | sys.stderr.write('File {path!r} was not found.\nPlease specify correct path to configuration.\n'.format( 104 | path=path)) 105 | sys.exit(1) 106 | 107 | try: 108 | severity = gixy.severity.ALL[args.level] 109 | except IndexError: 110 | sys.stderr.write('Too high level filtering. Maximum level: -{0}\n'.format('l' * (len(gixy.severity.ALL) - 1))) 111 | sys.exit(1) 112 | 113 | if args.tests: 114 | tests = [x.strip() for x in args.tests.split(',')] 115 | else: 116 | tests = None 117 | 118 | if args.skips: 119 | skips = [x.strip() for x in args.skips.split(',')] 120 | else: 121 | skips = None 122 | 123 | config = Config( 124 | severity=severity, 125 | output_format=args.output_format, 126 | output_file=args.output_file, 127 | plugins=tests, 128 | skips=skips, 129 | allow_includes=not args.disable_includes 130 | ) 131 | 132 | for plugin_cls in PluginsManager().plugins_classes: 133 | name = plugin_cls.__name__ 134 | options = copy.deepcopy(plugin_cls.options) 135 | for opt_key, opt_val in options.items(): 136 | option_name = '{name}:{key}'.format(name=name, key=opt_key) 137 | if option_name not in args: 138 | continue 139 | 140 | val = getattr(args, option_name) 141 | if val is None: 142 | continue 143 | 144 | if isinstance(opt_val, tuple): 145 | val = tuple([x.strip() for x in val.split(',')]) 146 | elif isinstance(opt_val, set): 147 | val = set([x.strip() for x in val.split(',')]) 148 | elif isinstance(opt_val, list): 149 | val = [x.strip() for x in val.split(',')] 150 | options[opt_key] = val 151 | config.set_for(name, options) 152 | 153 | formatter = formatters()[config.output_format]() 154 | failed = False 155 | for input_path in args.nginx_files: 156 | path = os.path.abspath(os.path.expanduser(input_path)) 157 | if not os.path.exists(path): 158 | LOG.error('File %s was not found', path) 159 | continue 160 | 161 | with Gixy(config=config) as yoda: 162 | try: 163 | if path == '-': 164 | with os.fdopen(sys.stdin.fileno(), 'rb') as fdata: 165 | yoda.audit('', fdata, is_stdin=True) 166 | else: 167 | with open(path, mode='rb') as fdata: 168 | yoda.audit(path, fdata, is_stdin=False) 169 | except InvalidConfiguration: 170 | failed = True 171 | formatter.feed(path, yoda) 172 | failed = failed or sum(yoda.stats.values()) > 0 173 | 174 | if args.output_file: 175 | with open(config.output_file, 'w') as f: 176 | f.write(formatter.flush()) 177 | else: 178 | print(formatter.flush()) 179 | 180 | if failed: 181 | # If something found - exit code must be 1, otherwise 0 182 | sys.exit(1) 183 | sys.exit(0) 184 | -------------------------------------------------------------------------------- /tests/directives/test_block.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_equals, assert_true, assert_false 2 | from tests.asserts import assert_is_instance, assert_is_none, assert_is_not_none 3 | from gixy.parser.nginx_parser import NginxParser 4 | from gixy.directives.block import * 5 | 6 | # TODO(buglloc): what about include block? 7 | 8 | 9 | def _get_parsed(config): 10 | root = NginxParser(cwd='', allow_includes=False).parse(config) 11 | return root.children[0] 12 | 13 | 14 | def test_block(): 15 | config = 'some {some;}' 16 | 17 | directive = _get_parsed(config) 18 | assert_is_instance(directive, Block) 19 | assert_true(directive.is_block) 20 | assert_true(directive.self_context) 21 | assert_false(directive.provide_variables) 22 | 23 | 24 | def test_http(): 25 | config = ''' 26 | http { 27 | default_type application/octet-stream; 28 | sendfile on; 29 | keepalive_timeout 65; 30 | } 31 | ''' 32 | 33 | directive = _get_parsed(config) 34 | assert_is_instance(directive, HttpBlock) 35 | assert_true(directive.is_block) 36 | assert_true(directive.self_context) 37 | assert_false(directive.provide_variables) 38 | 39 | 40 | def test_server(): 41 | config = ''' 42 | server { 43 | listen 80; 44 | server_name _; 45 | server_name cool.io; 46 | } 47 | 48 | ''' 49 | 50 | directive = _get_parsed(config) 51 | assert_is_instance(directive, ServerBlock) 52 | assert_true(directive.is_block) 53 | assert_true(directive.self_context) 54 | assert_equals([d.args[0] for d in directive.get_names()], ['_', 'cool.io']) 55 | assert_false(directive.provide_variables) 56 | 57 | 58 | def test_location(): 59 | config = ''' 60 | location / { 61 | } 62 | ''' 63 | 64 | directive = _get_parsed(config) 65 | assert_is_instance(directive, LocationBlock) 66 | assert_true(directive.is_block) 67 | assert_true(directive.self_context) 68 | assert_true(directive.provide_variables) 69 | assert_is_none(directive.modifier) 70 | assert_equals(directive.path, '/') 71 | assert_false(directive.is_internal) 72 | 73 | 74 | def test_location_internal(): 75 | config = ''' 76 | location / { 77 | internal; 78 | } 79 | ''' 80 | 81 | directive = _get_parsed(config) 82 | assert_is_instance(directive, LocationBlock) 83 | assert_true(directive.is_internal) 84 | 85 | 86 | def test_location_modifier(): 87 | config = ''' 88 | location = / { 89 | } 90 | ''' 91 | 92 | directive = _get_parsed(config) 93 | assert_is_instance(directive, LocationBlock) 94 | assert_equals(directive.modifier, '=') 95 | assert_equals(directive.path, '/') 96 | 97 | 98 | def test_if(): 99 | config = ''' 100 | if ($some) { 101 | } 102 | ''' 103 | 104 | directive = _get_parsed(config) 105 | assert_is_instance(directive, IfBlock) 106 | assert_true(directive.is_block) 107 | assert_false(directive.self_context) 108 | assert_false(directive.provide_variables) 109 | assert_equals(directive.variable, '$some') 110 | assert_is_none(directive.operand) 111 | assert_is_none(directive.value) 112 | 113 | 114 | def test_if_modifier(): 115 | config = ''' 116 | if (-f /some) { 117 | } 118 | ''' 119 | 120 | directive = _get_parsed(config) 121 | assert_is_instance(directive, IfBlock) 122 | assert_equals(directive.operand, '-f') 123 | assert_equals(directive.value, '/some') 124 | assert_is_none(directive.variable) 125 | 126 | 127 | def test_if_variable(): 128 | config = ''' 129 | if ($http_some = '/some') { 130 | } 131 | ''' 132 | 133 | directive = _get_parsed(config) 134 | assert_is_instance(directive, IfBlock) 135 | assert_equals(directive.variable, '$http_some') 136 | assert_equals(directive.operand, '=') 137 | assert_equals(directive.value, '/some') 138 | 139 | 140 | def test_block_some_flat(): 141 | config = ''' 142 | some { 143 | default_type application/octet-stream; 144 | sendfile on; 145 | if (-f /some/) { 146 | keepalive_timeout 65; 147 | } 148 | } 149 | ''' 150 | 151 | directive = _get_parsed(config) 152 | for d in ['default_type', 'sendfile', 'keepalive_timeout']: 153 | c = directive.some(d, flat=True) 154 | assert_is_not_none(c) 155 | assert_equals(c.name, d) 156 | 157 | 158 | def test_block_some_not_flat(): 159 | config = ''' 160 | some { 161 | default_type application/octet-stream; 162 | sendfile on; 163 | if (-f /some/) { 164 | keepalive_timeout 65; 165 | } 166 | } 167 | ''' 168 | 169 | directive = _get_parsed(config) 170 | c = directive.some('keepalive_timeout', flat=False) 171 | assert_is_none(c) 172 | 173 | 174 | def test_block_find_flat(): 175 | config = ''' 176 | some { 177 | directive 1; 178 | if (-f /some/) { 179 | directive 2; 180 | } 181 | } 182 | ''' 183 | 184 | directive = _get_parsed(config) 185 | finds = directive.find('directive', flat=True) 186 | assert_equals(len(finds), 2) 187 | assert_equals([x.name for x in finds], ['directive', 'directive']) 188 | assert_equals([x.args[0] for x in finds], ['1', '2']) 189 | 190 | 191 | def test_block_find_not_flat(): 192 | config = ''' 193 | some { 194 | directive 1; 195 | if (-f /some/) { 196 | directive 2; 197 | } 198 | } 199 | ''' 200 | 201 | directive = _get_parsed(config) 202 | finds = directive.find('directive', flat=False) 203 | assert_equals(len(finds), 1) 204 | assert_equals([x.name for x in finds], ['directive']) 205 | assert_equals([x.args[0] for x in finds], ['1']) 206 | 207 | 208 | def test_block_map(): 209 | config = ''' 210 | map $some_var $some_other_var { 211 | a b; 212 | default c; 213 | } 214 | ''' 215 | 216 | directive = _get_parsed(config) 217 | assert_is_instance(directive, MapBlock) 218 | assert_true(directive.is_block) 219 | assert_false(directive.self_context) 220 | assert_true(directive.provide_variables) 221 | assert_equals(directive.variable, 'some_other_var') 222 | 223 | 224 | def test_block_geo_two_vars(): 225 | config = ''' 226 | geo $some_var $some_other_var { 227 | 1.2.3.4 b; 228 | default c; 229 | } 230 | ''' 231 | 232 | directive = _get_parsed(config) 233 | assert_is_instance(directive, GeoBlock) 234 | assert_true(directive.is_block) 235 | assert_false(directive.self_context) 236 | assert_true(directive.provide_variables) 237 | assert_equals(directive.variable, 'some_other_var') 238 | 239 | 240 | def test_block_geo_one_var(): 241 | config = ''' 242 | geo $some_var { 243 | 5.6.7.8 d; 244 | default e; 245 | } 246 | ''' 247 | 248 | directive = _get_parsed(config) 249 | assert_is_instance(directive, GeoBlock) 250 | assert_true(directive.is_block) 251 | assert_false(directive.self_context) 252 | assert_true(directive.provide_variables) 253 | assert_equals(directive.variable, 'some_var') 254 | -------------------------------------------------------------------------------- /gixy/cli/argparser.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from configargparse import * 4 | from six.moves import StringIO 5 | 6 | from gixy.core.plugins_manager import PluginsManager 7 | 8 | # used while parsing args to keep track of where they came from 9 | _COMMAND_LINE_SOURCE_KEY = 'command_line' 10 | _ENV_VAR_SOURCE_KEY = 'environment_variables' 11 | _CONFIG_FILE_SOURCE_KEY = 'config_file' 12 | _DEFAULTS_SOURCE_KEY = 'defaults' 13 | 14 | 15 | class GixyConfigParser(DefaultConfigFileParser): 16 | def get_syntax_description(self): 17 | return '' 18 | 19 | def parse(self, stream): 20 | """Parses the keys + values from a config file.""" 21 | 22 | items = OrderedDict() 23 | prefix = '' 24 | for i, line in enumerate(stream): 25 | line = line.strip() 26 | if not line or line[0] in ['#', ';'] or line.startswith('---'): 27 | continue 28 | if line[0] == '[': 29 | prefix = '%s-' % line[1:-1].replace('_', '-') 30 | continue 31 | 32 | white_space = '\\s*' 33 | key = '(?P[^:=;#\s]+?)' 34 | value = white_space + '[:=\s]' + white_space + '(?P.+?)' 35 | comment = white_space + '(?P\\s[;#].*)?' 36 | 37 | key_only_match = re.match('^' + key + comment + '$', line) 38 | if key_only_match: 39 | key = key_only_match.group('key') 40 | items[key] = 'true' 41 | continue 42 | 43 | key_value_match = re.match('^' + key + value + comment + '$', line) 44 | if key_value_match: 45 | key = key_value_match.group('key') 46 | value = key_value_match.group('value') 47 | 48 | if value.startswith('[') and value.endswith(']'): 49 | # handle special case of lists 50 | value = [elem.strip() for elem in value[1:-1].split(',')] 51 | 52 | items[prefix + key] = value 53 | continue 54 | 55 | raise ConfigFileParserException('Unexpected line %s in %s: %s' % (i, 56 | getattr(stream, 'name', 'stream'), line)) 57 | return items 58 | 59 | def serialize(self, items): 60 | """Does the inverse of config parsing by taking parsed values and 61 | converting them back to a string representing config file contents. 62 | """ 63 | r = StringIO() 64 | for key, value in items.items(): 65 | if type(value) == OrderedDict: 66 | r.write('\n[%s]\n' % key) 67 | r.write(self.serialize(value)) 68 | else: 69 | value, help = value 70 | if help: 71 | r.write('; %s\n' % help) 72 | r.write('%s = %s\n' % (key, value)) 73 | return r.getvalue() 74 | 75 | 76 | class GixyHelpFormatter(HelpFormatter): 77 | def format_help(self): 78 | manager = PluginsManager() 79 | help_message = super(GixyHelpFormatter, self).format_help() 80 | if 'plugins options:' in help_message: 81 | # Print available blugins _only_ if we prints options for it 82 | plugins = '\n'.join('\t' + plugin.__name__ for plugin in manager.plugins_classes) 83 | help_message = '{orig}\n\navailable plugins:\n{plugins}\n'.format(orig=help_message, plugins=plugins) 84 | return help_message 85 | 86 | 87 | class ArgsParser(ArgumentParser): 88 | def get_possible_config_keys(self, action): 89 | """This method decides which actions can be set in a config file and 90 | what their keys will be. It returns a list of 0 or more config keys that 91 | can be used to set the given action's value in a config file. 92 | """ 93 | keys = [] 94 | for arg in action.option_strings: 95 | if arg in ['--config', '--write-config', '--version']: 96 | continue 97 | if any([arg.startswith(2 * c) for c in self.prefix_chars]): 98 | keys += [arg[2:], arg] # eg. for '--bla' return ['bla', '--bla'] 99 | 100 | return keys 101 | 102 | def get_items_for_config_file_output(self, source_to_settings, 103 | parsed_namespace): 104 | """Converts the given settings back to a dictionary that can be passed 105 | to ConfigFormatParser.serialize(..). 106 | 107 | Args: 108 | source_to_settings: the dictionary described in parse_known_args() 109 | parsed_namespace: namespace object created within parse_known_args() 110 | Returns: 111 | an OrderedDict where keys are strings and values are either strings 112 | or lists 113 | """ 114 | config_file_items = OrderedDict() 115 | for source, settings in source_to_settings.items(): 116 | if source == _COMMAND_LINE_SOURCE_KEY: 117 | _, existing_command_line_args = settings[''] 118 | for action in self._actions: 119 | config_file_keys = self.get_possible_config_keys(action) 120 | if config_file_keys and not action.is_positional_arg and \ 121 | already_on_command_line(existing_command_line_args, 122 | action.option_strings): 123 | value = getattr(parsed_namespace, action.dest, None) 124 | if value is not None: 125 | if type(value) is bool: 126 | value = str(value).lower() 127 | if ':' in action.dest: 128 | section, key = action.dest.split(':', 2) 129 | key = key.replace('_', '-') 130 | if section not in config_file_items: 131 | config_file_items[section] = OrderedDict() 132 | config_file_items[section][key] = (value, action.help) 133 | else: 134 | config_file_items[config_file_keys[0]] = (value, action.help) 135 | elif source.startswith(_CONFIG_FILE_SOURCE_KEY): 136 | for key, (action, value) in settings.items(): 137 | if ':' in action.dest: 138 | section, key = action.dest.split(':', 2) 139 | key = key.replace('_', '-') 140 | if section not in config_file_items: 141 | config_file_items[section] = OrderedDict() 142 | config_file_items[section][key] = (value, action.help) 143 | else: 144 | config_file_items[key] = (value, action.help) 145 | return config_file_items 146 | 147 | 148 | def create_parser(): 149 | return ArgsParser( 150 | description='Gixy - a Nginx configuration [sec]analyzer\n\n', 151 | formatter_class=GixyHelpFormatter, 152 | config_file_parser_class=GixyConfigParser, 153 | auto_env_var_prefix='GIXY_', 154 | add_env_var_help=False, 155 | default_config_files=['/etc/gixy/gixy.cfg', '~/.config/gixy/gixy.conf'], 156 | args_for_setting_config_path=['-c', '--config'], 157 | args_for_writing_out_config_file=['--write-config'], 158 | add_config_file_help=False 159 | ) 160 | -------------------------------------------------------------------------------- /tests/core/test_regexp.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_true, assert_false, assert_equals 2 | from gixy.core.regexp import Regexp 3 | 4 | ''' 5 | CATEGORIES: 6 | sre_parse.CATEGORY_SPACE 7 | sre_parse.CATEGORY_NOT_SPACE 8 | sre_parse.CATEGORY_DIGIT 9 | sre_parse.CATEGORY_NOT_DIGIT 10 | sre_parse.CATEGORY_WORD 11 | sre_parse.CATEGORY_NOT_WORD 12 | ANY 13 | ''' 14 | 15 | 16 | def test_positive_contains(): 17 | cases = ( 18 | (r'[a-z]', 'a'), 19 | (r'[a-z]*', 'a'), 20 | (r'[a-z]*?', 'a'), 21 | (r'[a-z]+?', 'a'), 22 | (r'[a-z]', 'z'), 23 | (r'(?:a|b)', 'b'), 24 | (r'(/|:|[a-z])', 'g'), 25 | (r'[^a-z]', '/'), 26 | (r'[^a-z]', '\n'), 27 | (r'[^0]', '9'), 28 | (r'[^0-2]', '3'), 29 | (r'[^0123a-z]', '9'), 30 | (r'\s', '\x20'), 31 | (r'[^\s]', 'a'), 32 | (r'\d', '1'), 33 | (r'[^\d]', 'b'), 34 | (r'\w', '_'), 35 | (r'[^\w]', '\n'), 36 | (r'\W', '\n'), 37 | (r'[^\W]', 'a'), 38 | (r'.', 'a') 39 | ) 40 | for case in cases: 41 | regexp, char = case 42 | yield check_positive_contain, regexp, char 43 | 44 | 45 | def test_negative_contains(): 46 | cases = ( 47 | ('[a-z]', '1'), 48 | ('[a-z]*', '2'), 49 | ('[a-z]*?', '3'), 50 | ('[a-z]+?', '4'), 51 | ('[a-z]', '\n'), 52 | ('(?:a|b)', 'c'), 53 | ('(/|:|[a-z])', '\n'), 54 | ('[^a-z]', 'a'), 55 | ('[^0]', '0'), 56 | ('[^0-2]', '0'), 57 | ('[^0123a-z]', 'z'), 58 | (r'\s', 'a'), 59 | (r'[^\s]', '\n'), 60 | (r'\d', 'f'), 61 | (r'[^\d]', '2'), 62 | (r'\w', '\n'), 63 | (r'[^\w]', '_'), 64 | (r'\W', 'a'), 65 | (r'[^\W]', '\n'), 66 | (r'.', '\n') 67 | ) 68 | for case in cases: 69 | regexp, char = case 70 | yield check_negative_contain, regexp, char 71 | 72 | 73 | def test_groups_names(): 74 | cases = ( 75 | ('foo', [0]), 76 | ('(1)(2)(?:3)', [0, 1, 2]), 77 | ('(1)((2)|(?:3))', [0, 1, 2, 3]), 78 | ("(?'pcre_7'1as)(?P(?2)|(?:3))", [0, 1, 2, 3, 'pcre_7', 'outer', 'inner']), 79 | ('/proxy/(?.*)$', [0, 1, 'proxy']) 80 | ) 81 | for case in cases: 82 | regexp, groups = case 83 | yield check_groups_names, regexp, groups 84 | 85 | 86 | def test_to_string(): 87 | cases = ( 88 | (r'foo', 'foo'), 89 | (r'(1)(2)(?:3)', '(1)(2)(?:3)'), 90 | (r'(1)((2)|(?:3))', '(1)((?:(2)|(?:3)))'), 91 | (r'\w|1|3-5|[a-z]', '(?:[\w]|1|3\\-5|[a-z])'), 92 | (r'(1|(?:3)|([4-6]))', '((?:1|(?:3)|([4-6])))'), 93 | (r'(1|(?:3)|(?P[4-6]))', '((?:1|(?:3)|([4-6])))'), 94 | (r'^sss', '^sss'), 95 | (r'(^bb|11)$', '((?:^bb|11))$'), 96 | (r'(http|https)', '(http(?:|s))'), 97 | (r'1*', '1*'), 98 | (r'1*?', '1*?'), 99 | (r'1+', '1+'), 100 | ) 101 | for case in cases: 102 | regexp, string = case 103 | yield check_to_string, regexp, string 104 | 105 | 106 | def test_positive_startswith(): 107 | cases = ( 108 | (r'foo', 'q', False), 109 | (r'foo', 'f', True), 110 | (r'^foo', 'f', False), 111 | (r'(^foo)', 'f', False), 112 | (r'(^foo)', 'f', True), 113 | (r'(^foo|g)', 'f', True), 114 | (r'(^foo|g)', 'g', True), 115 | (r'(^foo|g)', 'q', False), 116 | (r'^[^/]+', '\n', True), 117 | (r'/[^/]+', '/', True), 118 | (r'((a))', 'a', False), 119 | (r'((a))', 'b', False), 120 | (r'^[a-z]{0}0', '0', False), 121 | (r'^[a-z]{1}0', 'a', False), 122 | ) 123 | for case in cases: 124 | regexp, check, strict = case 125 | yield check_positive_startswith, regexp, check, strict 126 | 127 | 128 | def test_negative_startswith(): 129 | cases = ( 130 | (r'foo', '\n', False), 131 | (r'foo', 'o', True), 132 | (r'^foo', 'o', False), 133 | (r'(^foo)', 'q', False), 134 | (r'(^foo)', 'q', True), 135 | (r'(^foo|g)', 'q', True), 136 | (r'(^foo|g)', 'o', True), 137 | (r'(^foo|g)', '\n', False), 138 | (r'^[^/]+', '/', True), 139 | (r'/[^/]+', 'a', True), 140 | (r'((abc)|(ss))', 'b', True), 141 | (r'^[a-z]{0}0', 'a', False), 142 | (r'^[a-z]{0}0', 'g', False), 143 | ) 144 | for case in cases: 145 | regexp, check, strict = case 146 | yield check_negative_startswith, regexp, check, strict 147 | 148 | 149 | def test_positive_must_contain(): 150 | cases = ( 151 | (r'abc', 'a'), 152 | (r'abc', 'b'), 153 | (r'abc', 'c'), 154 | (r'3+', '3'), 155 | (r'[0]', '0'), 156 | (r'([0])', '0'), 157 | (r'(?:[0])', '0'), 158 | (r'(?:[0])|0|((((0))))', '0'), 159 | ) 160 | for case in cases: 161 | regexp, char = case 162 | yield check_positive_must_contain, regexp, char 163 | 164 | 165 | def test_negative_must_contain(): 166 | cases = ( 167 | (r'[a-z]', '1'), 168 | (r'2{0}1', '2'), 169 | (r'3?', '3'), 170 | (r'3*', '3'), 171 | (r'3*?', '3'), 172 | (r'3+a', 'b'), 173 | (r'[a-z]', 'a'), 174 | (r'(?:a|b)', 'a'), 175 | (r'(?:a|b)', 'b'), 176 | (r'(/|:|[a-z])', '/'), 177 | (r'(/|:|[a-z])', 'z'), 178 | (r'[^a-z]', '\n'), 179 | (r'[^0]', '0'), 180 | (r'[^0-2]', '0'), 181 | (r'[^0123a-z]', 'z'), 182 | (r'\s', '\x20'), 183 | (r'[^\s]', '\n'), 184 | (r'\d', '3'), 185 | (r'[^\d]', 'a'), 186 | (r'\w', 'a'), 187 | (r'[^\w]', '\n'), 188 | (r'\W', '\n'), 189 | (r'[^\W]', 'a'), 190 | (r'.', '\n') 191 | ) 192 | for case in cases: 193 | regexp, char = case 194 | yield check_negative_must_contain, regexp, char 195 | 196 | 197 | def test_positive_must_startswith(): 198 | cases = ( 199 | (r'foo', 'f', True), 200 | (r'^foo', 'f', False), 201 | (r'(^foo)', 'f', True), 202 | (r'^((a))', 'a', False), 203 | (r'((a))', 'a', True), 204 | (r'^[a-z]{0}0', '0', False), 205 | (r'^a{1}0', 'a', False), 206 | ) 207 | for case in cases: 208 | regexp, check, strict = case 209 | yield check_positive_must_startswith, regexp, check, strict 210 | 211 | 212 | def test_negative_must_startswith(): 213 | cases = ( 214 | (r'foo', 'o', False), 215 | (r'^foo', 'o', False), 216 | (r'(^foo)', 'o', False), 217 | (r'[a-z]', '1', True), 218 | (r'[a-z]', 'a', True), 219 | (r'/[^/]+', 'a', True), 220 | (r'3?', '3', True), 221 | (r'3*', '3', True), 222 | (r'3*?', '3', True), 223 | (r'3+a', 'b', True), 224 | (r'^((a))', 'b', False), 225 | (r'((a))', 'a', False), 226 | (r'^a{0}0', 'a', False), 227 | ) 228 | for case in cases: 229 | regexp, check, strict = case 230 | yield check_negative_must_startswith, regexp, check, strict 231 | 232 | 233 | def test_generate(): 234 | cases = ( 235 | (r'foo', ['foo']), 236 | (r'^sss', ['^sss']), 237 | (r'(1)(2)(3)', ['123']), 238 | (r'(1)((2)|(?:3))', ['12', '13']), 239 | (r'(^1?2?|aa/)', ['^', '^1', '^2', '^12', 'aa/']), 240 | (r'^https?://yandex.ru', ['^http://yandex|ru', '^https://yandex|ru']), 241 | (r'(^bb|11)$', ['^bb$', '11$']), 242 | (r'(http|https)', ['http', 'https']), 243 | (r'1*', ['', '11111']), 244 | (r'1*?', ['', '11111']), 245 | (r'1[0]?2', ['102', '12']), 246 | (r'1[0]2', ['102']), 247 | (r'1+', ['11111']), 248 | (r'[^/]?', ['', '|']), 249 | (r'^http://(foo|bar)|baz', ['^http://foo', '^http://bar', 'baz']), 250 | (r'[^\x00-\x7b|\x7e-\xff]', ['\x7d']), 251 | (r'(a|b|c)', ['a', 'b', 'c']), 252 | (r'[xyz]', ['x', 'y', 'z']) 253 | ) 254 | for case in cases: 255 | regexp, values = case 256 | yield check_generate, regexp, values 257 | 258 | 259 | def test_strict_generate(): 260 | reg = Regexp('^foo|bar', strict=True) 261 | assert_equals(sorted(reg.generate('|', anchored=True)), sorted(['^foo', '^bar'])) 262 | 263 | 264 | def test_gen_anchor(): 265 | 266 | reg = Regexp('^some$') 267 | val = next(reg.generate('', anchored=False)) 268 | assert_equals(val, 'some') 269 | 270 | reg = Regexp('^some$') 271 | val = next(reg.generate('', anchored=True)) 272 | assert_equals(val, '^some$') 273 | 274 | reg = Regexp('^some$', strict=True) 275 | val = next(reg.generate('', anchored=False)) 276 | assert_equals(val, 'some') 277 | 278 | reg = Regexp('^some$', strict=True) 279 | val = next(reg.generate('', anchored=True)) 280 | assert_equals(val, '^some$') 281 | 282 | 283 | def test_group_can_contains(): 284 | source = '/some/(?P[^/:.]+)/' 285 | reg = Regexp(source) 286 | assert_true(reg.can_contain('\n'), 287 | 'Whole regex "{src}" can contains {sym!r}'.format(src=source, sym='\\n')) 288 | 289 | assert_true(reg.group(0).can_contain('\n'), 290 | 'Group 0 from regex "{src}" can contains {sym!r}'.format(src=source, sym='\\n')) 291 | 292 | assert_true(reg.group('action').can_contain('\n'), 293 | 'Group "action" from regex "{src}" can contains {sym!r}'.format(src=source, sym='\\n')) 294 | 295 | assert_true(reg.group(1).can_contain('\n'), 296 | 'Group 1 from regex "{src}" can contains {sym!r}'.format(src=source, sym='\\n')) 297 | 298 | assert_false(reg.group('action').can_contain('/'), 299 | 'Group "action" from regex "{src}" CAN\'T (!) contain {sym!r}'.format(src=source, sym='/')) 300 | 301 | 302 | def check_positive_contain(regexp, char): 303 | reg = Regexp(regexp, case_sensitive=True) 304 | assert_true(reg.can_contain(char), 305 | '{reg!r} should contain {chr!r}'.format(reg=regexp, chr=char)) 306 | 307 | reg = Regexp(regexp, case_sensitive=False) 308 | char = char.upper() 309 | assert_true(reg.can_contain(char), 310 | '{reg!r} (case insensitive) should contain {chr!r}'.format(reg=regexp, chr=char)) 311 | 312 | 313 | def check_negative_contain(regexp, char): 314 | reg = Regexp(regexp, case_sensitive=True) 315 | assert_false(reg.can_contain(char), 316 | '{reg!r} should not contain {chr!r}'.format(reg=regexp, chr=char)) 317 | 318 | reg = Regexp(regexp, case_sensitive=False) 319 | char = char.upper() 320 | assert_false(reg.can_contain(char), 321 | '{reg!r} (case insensitive) should not contain {chr!r}'.format(reg=regexp, chr=char)) 322 | 323 | 324 | def check_positive_startswith(regexp, char, strict): 325 | reg = Regexp(regexp, case_sensitive=True, strict=strict) 326 | assert_true(reg.can_startswith(char), 327 | '{reg!r} can start\'s with {chr!r}'.format(reg=regexp, chr=char)) 328 | 329 | reg = Regexp(regexp, case_sensitive=False, strict=strict) 330 | char = char.upper() 331 | assert_true(reg.can_startswith(char), 332 | '{reg!r} (case insensitive) can start\'s with {chr!r}'.format(reg=regexp, chr=char)) 333 | 334 | 335 | def check_negative_startswith(regexp, char, strict): 336 | reg = Regexp(regexp, case_sensitive=True, strict=strict) 337 | assert_false(reg.can_startswith(char), 338 | '{reg!r} can\'t start\'s with {chr!r}'.format(reg=regexp, chr=char)) 339 | 340 | reg = Regexp(regexp, case_sensitive=False, strict=strict) 341 | char = char.upper() 342 | assert_false(reg.can_startswith(char), 343 | '{reg!r} (case insensitive) can\'t start\'s with {chr!r}'.format(reg=regexp, chr=char)) 344 | 345 | 346 | def check_groups_names(regexp, groups): 347 | reg = Regexp(regexp) 348 | assert_equals(set(reg.groups.keys()), set(groups)) 349 | 350 | 351 | def check_to_string(regexp, string): 352 | reg = Regexp(regexp) 353 | assert_equals(str(reg), string) 354 | 355 | 356 | def check_positive_must_contain(regexp, char): 357 | reg = Regexp(regexp, case_sensitive=True) 358 | assert_true(reg.must_contain(char), 359 | '{reg!r} must contain with {chr!r}'.format(reg=regexp, chr=char)) 360 | 361 | reg = Regexp(regexp, case_sensitive=False) 362 | char = char.upper() 363 | assert_true(reg.must_contain(char), 364 | '{reg!r} (case insensitive) must contain with {chr!r}'.format(reg=regexp, chr=char)) 365 | 366 | 367 | def check_negative_must_contain(regexp, char): 368 | reg = Regexp(regexp, case_sensitive=True) 369 | assert_false(reg.must_contain(char), 370 | '{reg!r} must NOT contain with {chr!r}'.format(reg=regexp, chr=char)) 371 | 372 | reg = Regexp(regexp, case_sensitive=False) 373 | char = char.upper() 374 | assert_false(reg.must_contain(char), 375 | '{reg!r} (case insensitive) must NOT contain with {chr!r}'.format(reg=regexp, chr=char)) 376 | 377 | 378 | def check_positive_must_startswith(regexp, char, strict): 379 | reg = Regexp(regexp, case_sensitive=True, strict=strict) 380 | assert_true(reg.must_startswith(char), 381 | '{reg!r} MUST start\'s with {chr!r}'.format(reg=regexp, chr=char)) 382 | 383 | reg = Regexp(regexp, case_sensitive=False, strict=strict) 384 | char = char.upper() 385 | assert_true(reg.must_startswith(char), 386 | '{reg!r} (case insensitive) MUST start\'s with {chr!r}'.format(reg=regexp, chr=char)) 387 | 388 | 389 | def check_negative_must_startswith(regexp, char, strict): 390 | reg = Regexp(regexp, case_sensitive=True, strict=strict) 391 | assert_false(reg.must_startswith(char), 392 | '{reg!r} MUST NOT start\'s with {chr!r}'.format(reg=regexp, chr=char)) 393 | 394 | reg = Regexp(regexp, case_sensitive=False, strict=strict) 395 | char = char.upper() 396 | assert_false(reg.must_startswith(char), 397 | '{reg!r} (case insensitive) MUST NOT start\'s with {chr!r}'.format(reg=regexp, chr=char)) 398 | 399 | 400 | def check_generate(regexp, values): 401 | reg = Regexp(regexp) 402 | assert_equals(sorted(reg.generate('|', anchored=True)), sorted(values)) 403 | --------------------------------------------------------------------------------