Repository navigation
feat(scan): add zammad_version_scan module (#1810) #1826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
016e7cd
9baa46f
8ef5564
034b9c1
6ca4fc4
db6e637
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| info: | ||
| name: zammad_version_scan | ||
| author: devjoshi2005 | ||
| severity: 3 | ||
| description: > | ||
| Zammad version scan - detects a Zammad helpdesk instance via its web | ||
| branding and probes the /api/v1/version endpoint. Properly configured | ||
| Zammad deployments protect the version endpoint with authentication | ||
| (unauthenticated requests receive Zammad's distinctive "Authentication | ||
| required" JSON error); the module reports this as the version-unknown | ||
| outcome instead of guessing a version. It does not assess | ||
| vulnerabilities. | ||
| reference: | ||
| - https://docs.zammad.org/ | ||
| - https://github.com/zammad/zammad | ||
| profiles: | ||
| - scan | ||
| - http | ||
| - info_severity | ||
| - zammad | ||
|
|
||
| payloads: | ||
| - library: http | ||
| steps: | ||
| # Step 1: product confirmation on the unauthenticated root page. | ||
| # Real Zammad serves the single-page application at / whose layout | ||
| # includes a title element followed by the product-specific script | ||
| # id="zammad_form_script" (verified against a current public Zammad | ||
| # instance). The title text is configurable, so identity is anchored | ||
| # on the script marker, not on the title wording: pages that merely | ||
| # mention Zammad, or non-Zammad pages carrying a "Zammad" title, | ||
| # cannot match. The log states only what this step observed; whether | ||
| # the version endpoint is auth-protected is reported by step 2. | ||
| - method: get | ||
| timeout: 3 | ||
| headers: | ||
| User-Agent: "{user_agent}" | ||
| allow_redirects: true | ||
| ssl: false | ||
| url: | ||
| nettacker_fuzzer: | ||
| input_format: "{{schema}}://{target}:{{ports}}/" | ||
| prefix: "" | ||
| suffix: "" | ||
| interceptors: | ||
| data: | ||
| schema: | ||
| - "http" | ||
| - "https" | ||
| ports: | ||
| - 80 | ||
| - 443 | ||
| - 8080 | ||
| response: | ||
| condition_type: and | ||
| log: "Zammad helpdesk detected, version not assessed" | ||
| conditions: | ||
| status_code: | ||
| regex: "200" | ||
| reverse: false | ||
| content: | ||
| # The application layout emits the product-specific script | ||
| # id="zammad_form_script" after the title element (verified | ||
| # against a current public Zammad instance). The title text is | ||
| # configurable, so any title element is accepted; identity is | ||
| # anchored on the script marker. The lookahead requires the | ||
| # marker while keeping the stored regex match limited to the | ||
| # short title element. | ||
| regex: '<title>[^<]*</title>(?=[\s\S]*<script[^>]*\bid="zammad_form_script")' | ||
| reverse: false | ||
|
|
||
| # Step 2: version unknown outcome - the unauthenticated version API is | ||
|
Comment on lines
+59
to
+72
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # rejected with Zammad's distinctive JSON error (verified on a current | ||
| # Zammad instance: "error_human":"Authentication required" among the | ||
| # returned keys). "Version unknown" is a | ||
| # first-class outcome, never a guessed version. Reverse proxies and | ||
| # other products reject the same request with HTML pages, so they | ||
| # cannot trigger this step. | ||
| - method: get | ||
| timeout: 3 | ||
| headers: | ||
| User-Agent: "{user_agent}" | ||
| allow_redirects: false | ||
| ssl: false | ||
| url: | ||
| nettacker_fuzzer: | ||
| input_format: "{{schema}}://{target}:{{ports}}/api/v1/version" | ||
| prefix: "" | ||
| suffix: "" | ||
| interceptors: | ||
| data: | ||
| schema: | ||
| - "http" | ||
| - "https" | ||
| ports: | ||
| - 80 | ||
| - 443 | ||
| - 8080 | ||
| response: | ||
| condition_type: and | ||
| log: "Zammad version endpoint accessible, version unknown (protected by authentication)" | ||
| conditions: | ||
| status_code: | ||
| regex: "401|403" | ||
| reverse: false | ||
| content: | ||
| regex: '"error_human"\s*:\s*"Authentication required"' | ||
| reverse: false | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| """Behavioral regression tests for the zammad_version_scan module. | ||
|
|
||
| These tests execute the module's HTTP response conditions exactly as the | ||
| engine does (response_conditions_matched) and assert the module's outcome | ||
| matrix, including the false-positive controls required by issue #1810: | ||
| non-Zammad targets must produce no Zammad event. | ||
| """ | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| import yaml | ||
|
|
||
| from nettacker.core.lib.http import response_conditions_matched | ||
|
|
||
| MODULE_PATH = ( | ||
| Path(__file__).resolve().parents[2] / "nettacker" / "modules" / "scan" / "zammad_version.yaml" | ||
| ) | ||
|
|
||
|
|
||
| def _steps(): | ||
| module = yaml.safe_load(MODULE_PATH.read_text(encoding="utf-8")) | ||
| steps = module["payloads"][0]["steps"] | ||
| return steps[0], steps[1] | ||
|
|
||
|
|
||
| def _response(status_code, content): | ||
| return { | ||
| "reason": "OK", | ||
| "url": "http://127.0.0.1:8080/", | ||
| "status_code": str(status_code), | ||
| "content": content, | ||
| "headers": {"Server": "Mock"}, | ||
| "responsetime": 0.1, | ||
| } | ||
|
|
||
|
|
||
| def _root_html(title, body, extra_head=""): | ||
| """A page with the given <title>, body content and optional head extras.""" | ||
| return f"<html><head><title>{title}</title>{extra_head}</head><body>{body}</body></html>" | ||
|
|
||
|
|
||
| # Zammad root-page identity, verified against a current public Zammad | ||
| # instance: a title element followed by the Rails CSRF meta tag and the | ||
| # product-specific form script element. The title text is configurable, so | ||
| # identity is anchored on the script marker, not on the title wording. | ||
| ZAMMAD_CSRF_META = '<meta name="csrf-param" content="authenticity_token">' | ||
| ZAMMAD_FORM_SCRIPT = ( | ||
| '<script src="/assets/form/form.js" nonce="n" defer="defer" id="zammad_form_script"></script>' | ||
| ) | ||
| ZAMMAD_IDENTITY_HEAD = ZAMMAD_CSRF_META + ZAMMAD_FORM_SCRIPT | ||
| GENERIC_CSRF_META = '<meta name="csrf-param" content="csrf">' | ||
|
|
||
| ZAMMAD_ROOT = _root_html("Zammad", "", ZAMMAD_IDENTITY_HEAD) | ||
| CUSTOM_TITLE_ZAMMAD_ROOT = _root_html("Acme Support", "", ZAMMAD_IDENTITY_HEAD) | ||
| WORDPRESS_ROOT = _root_html( | ||
| "My WordPress Site", "<p>Welcome to WordPress. This is your first post.</p>" | ||
| ) | ||
| # Page that merely mentions Zammad in its body (CodeRabbit review scenario). | ||
| MENTIONS_ZAMMAD_ROOT = _root_html( | ||
| "Hosting Blog", "<p>Zammad is a popular open-source helpdesk platform.</p>" | ||
| ) | ||
| # Non-Zammad page that carries a "Zammad" title and only a generic CSRF meta | ||
| # but lacks the product-specific script marker (title-mirror regression). | ||
| ZAMMAD_TITLE_MIRROR_ROOT = _root_html( | ||
| "Zammad", "<p>Fan mirror of helpdesk documentation.</p>", GENERIC_CSRF_META | ||
| ) | ||
| ZAMMAD_AUTH_JSON = '{"error":"Authentication required","error_human":"Authentication required"}' | ||
| GENERIC_403_HTML = ( | ||
| "<html><head><title>403 Forbidden</title></head><body><h1>403 Forbidden</h1></body></html>" | ||
| ) | ||
| VERSION_JSON = '{"version":"6.5.4"}' | ||
|
|
||
|
|
||
| def test_step1_detects_zammad_root_page(): | ||
| """Full outcome: detection is reported without claiming authentication state.""" | ||
| branding_step, _ = _steps() | ||
| result = response_conditions_matched(branding_step, _response(200, ZAMMAD_ROOT)) | ||
| assert result, "Zammad root page must match the branding step" | ||
| assert result["log"] == "Zammad helpdesk detected, version not assessed" | ||
| assert "requires authentication" not in result["log"] | ||
|
|
||
|
|
||
| def test_step1_accepts_customized_title_with_zammad_script_marker(): | ||
| """The title text is configurable in Zammad; identity is the script marker.""" | ||
| branding_step, _ = _steps() | ||
| assert response_conditions_matched(branding_step, _response(200, CUSTOM_TITLE_ZAMMAD_ROOT)) | ||
|
|
||
|
|
||
| def test_step1_rejects_non_zammad_page(): | ||
| branding_step, _ = _steps() | ||
| assert not response_conditions_matched(branding_step, _response(200, WORDPRESS_ROOT)) | ||
|
|
||
|
|
||
| def test_step1_rejects_page_merely_mentioning_zammad(): | ||
| branding_step, _ = _steps() | ||
| assert not response_conditions_matched(branding_step, _response(200, MENTIONS_ZAMMAD_ROOT)) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| def test_step1_rejects_zammad_titled_page_without_zammad_script_marker(): | ||
| branding_step, _ = _steps() | ||
| assert not response_conditions_matched(branding_step, _response(200, ZAMMAD_TITLE_MIRROR_ROOT)) | ||
|
|
||
|
|
||
| def test_step1_rejects_wrong_status_code(): | ||
| branding_step, _ = _steps() | ||
| assert not response_conditions_matched(branding_step, _response(403, ZAMMAD_ROOT)) | ||
|
|
||
|
|
||
| def test_step2_matches_zammad_auth_required_json(): | ||
| _, version_step = _steps() | ||
| for status in ("401", "403"): | ||
| result = response_conditions_matched(version_step, _response(status, ZAMMAD_AUTH_JSON)) | ||
| assert result, f"Zammad {status} auth JSON must match the version step" | ||
| assert "version unknown" in result["log"] | ||
|
|
||
|
|
||
| def test_step2_rejects_non_zammad_403_page(): | ||
| """Reverse proxies and other products reject the request with HTML, not Zammad JSON.""" | ||
| _, version_step = _steps() | ||
| assert not response_conditions_matched(version_step, _response(403, GENERIC_403_HTML)) | ||
|
|
||
|
|
||
| def test_step2_rejects_exposed_version_json_without_auth(): | ||
| """A 200 response can never trigger the protected-version outcome.""" | ||
| _, version_step = _steps() | ||
| assert not response_conditions_matched(version_step, _response(200, VERSION_JSON)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Describe the request as unauthenticated, not the endpoint.
/api/v1/versionis authentication-protected. The module sends an unauthenticated request and recognizes the authentication error response. (raw.githubusercontent.com)Change this to: “probes the authentication-protected
/api/v1/versionendpoint with an unauthenticated request.”