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 3 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,97 @@ | ||
| 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 / with | ||
| # <title>Zammad</title> (verified against a current public Zammad | ||
| # instance). Non-Zammad targets cannot match and are never | ||
| # misreported. | ||
| - 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 (version endpoint requires authentication)" | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| conditions: | ||
| status_code: | ||
| regex: "200" | ||
| reverse: false | ||
| content: | ||
| regex: '<title>\s*Zammad' | ||
| 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,99 @@ | ||
| """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 control required by issue #1810: | ||
| a non-Zammad target 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" | ||
| ) | ||
|
|
||
| ZAMMAD_BRAND = '<meta name="csrf-token" content="z">' | ||
|
|
||
|
|
||
| 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 _branded_root_html(title): | ||
| """A page whose title element is set to `title`, plus CSRF meta.""" | ||
| return f"<html><head><title>{title}</title>{ZAMMAD_BRAND}</head><body></body></html>" | ||
|
|
||
|
|
||
| ZAMMAD_ROOT = _branded_root_html("Zammad") | ||
| WORDPRESS_ROOT = ( | ||
| "<html><head><title>My WordPress Site</title></head>" | ||
| "<body><p>Welcome to WordPress. This is your first post.</p></body></html>" | ||
| ) | ||
| MENTIONS_ZAMMAD_ROOT = ( | ||
| "<html><head><title>Hosting Blog</title></head>" | ||
| "<body><p>Zammad is a popular open-source helpdesk platform.</p></body></html>" | ||
| ) | ||
| 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(): | ||
| branding_step, _ = _steps() | ||
| result = response_conditions_matched(branding_step, _response(200, ZAMMAD_ROOT)) | ||
| assert result, "Zammad root page must match the branding step" | ||
| assert "Zammad helpdesk detected" in result["log"] | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| 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_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.”