Skip to content

Commit b9df777

Browse files
committed
Add 20 high fidelity curated rules: exposure and open service checks
Each proves before it reports: a specific content signature or a response header, always against a control, never a bare 200. Spring Boot actuator (env, heap dump, mappings, Jolokia), phpinfo pages, Apache mod_status and mod_info, a served .git/config and WEB-INF/web.xml, and open data stores and consoles (Elasticsearch, CouchDB, Prometheus, RabbitMQ, Airflow, the Kubernetes API, the Docker engine API, the H2 console, Jenkins), plus a CORS check that only fires when the site reflects an attacker origin and allows credentials both. The signatures are tight on purpose: phpinfo needs its title and the version heading, the git config must not be an HTML page, the CORS check needs the reflection and the credentials flag, so a page that merely mentions a word does not trip them. They seed disabled, like every curated rule.
1 parent bfe486f commit b9df777

20 files changed

Lines changed: 565 additions & 13 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-airflow-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "high",
6+
"title": "Apache Airflow is reachable without signing in",
7+
"summary": "The Airflow API answers unauthenticated, which exposes the pipelines and can run or change them.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/health"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"metadatabase\") and body_contains(\"scheduler\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/health",
23+
"remediate": "Require authentication on the Airflow web server.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"airflow"
31+
]
32+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"id": "access-control-cors-origin-reflected",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "high",
6+
"title": "CORS reflects any origin with credentials",
7+
"summary": "The site echoes any Origin into Access-Control-Allow-Origin and allows credentials, so a page on an attacker's site can read this site's authenticated responses.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/",
14+
"headers": {
15+
"Origin": "https://gcr-cors-probe.invalid"
16+
}
17+
}
18+
}
19+
],
20+
"confirm": {
21+
"control": "A path that cannot exist, so the site's not found answer is known.",
22+
"differential": "header(\"access-control-allow-origin\") == \"https://gcr-cors-probe.invalid\" and header(\"access-control-allow-credentials\") == \"true\"",
23+
"retest": 2
24+
},
25+
"reproduce": "curl -s -i https://host/ -H 'Origin: https://gcr-cors-probe.invalid'",
26+
"remediate": "Do not reflect the Origin. Allow only a fixed allow list, and never with credentials for a wildcard.",
27+
"references": [],
28+
"mappings": {
29+
"cwe": "CWE-942",
30+
"owasp": "A05:2021"
31+
},
32+
"rulesets": [
33+
"cors"
34+
]
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-couchdb-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "high",
6+
"title": "CouchDB is reachable without signing in",
7+
"summary": "The CouchDB HTTP API answers unauthenticated at the root, a direct path to the stored data.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"couchdb\") and body_contains(\"Welcome\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/",
23+
"remediate": "Require authentication and restrict the network.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"couchdb"
31+
]
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-docker-api-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "critical",
6+
"title": "The Docker engine API is reachable",
7+
"summary": "The Docker remote API answers unauthenticated, which is control of the host: an attacker can start a container that mounts the disk.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/version"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"ApiVersion\") and body_contains(\"GoVersion\") and body_contains(\"Os\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/version",
23+
"remediate": "Never expose the Docker API; bind it to a local socket with TLS and auth.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"docker"
31+
]
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-elasticsearch-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "critical",
6+
"title": "Elasticsearch is reachable without signing in",
7+
"summary": "The Elasticsearch HTTP API answers unauthenticated, which exposes and lets an attacker read or change the indexed data.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and (body_contains(\"You Know, for Search\") or (body_contains(\"cluster_name\") and body_contains(\"cluster_uuid\"))) and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/",
23+
"remediate": "Put Elasticsearch behind authentication and off the public internet.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"elastic"
31+
]
32+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"id": "access-control-h2-console-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "high",
6+
"title": "The H2 database console is exposed",
7+
"summary": "The H2 web console is reachable. It connects to a database and is a known path to code execution.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/h2-console/"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"H2 Console\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/h2-console/",
23+
"remediate": "Disable the H2 console in production.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"h2",
31+
"java"
32+
]
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-jenkins-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "medium",
6+
"title": "A Jenkins server is exposed",
7+
"summary": "A Jenkins server answers here and reveals its version in a header. Exposed Jenkins is a frequent path to code execution.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "header(\"x-jenkins\") != \"\"",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/",
23+
"remediate": "Put Jenkins behind authentication and off the public internet.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"jenkins"
31+
]
32+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"id": "access-control-jolokia-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "high",
6+
"title": "Jolokia JMX bridge is reachable",
7+
"summary": "Jolokia exposes JMX over HTTP. Reachable without authentication it can read and set JMX attributes, a common path to code execution.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/jolokia/version"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"jolokia\") and body_contains(\"agent\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/jolokia/version",
23+
"remediate": "Put Jolokia behind authentication, or do not expose it.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"spring",
31+
"jolokia"
32+
]
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-kubernetes-api-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "critical",
6+
"title": "The Kubernetes API answers unauthenticated",
7+
"summary": "The Kubernetes API server returns its version to an unauthenticated caller, which usually means the cluster can be read or controlled.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/version"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"gitVersion\") and body_contains(\"goVersion\") and body_differs(control)",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/version",
23+
"remediate": "Require authentication and authorization on the API server.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"kubernetes"
31+
]
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-prometheus-open",
3+
"class": "access-control",
4+
"safety": "read",
5+
"severity_floor": "medium",
6+
"title": "Prometheus is reachable without signing in",
7+
"summary": "Prometheus answers unauthenticated, which exposes metrics and the targets and internal addresses it scrapes.",
8+
"needs": [],
9+
"probe": [
10+
{
11+
"request": {
12+
"method": "GET",
13+
"path": "/-/healthy"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A path that cannot exist, so the site's not found answer is known.",
19+
"differential": "status==200 and body_contains(\"Prometheus Server is Healthy\")",
20+
"retest": 2
21+
},
22+
"reproduce": "curl -s https://host/-/healthy",
23+
"remediate": "Put Prometheus behind authentication.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-306",
27+
"owasp": "A01:2021"
28+
},
29+
"rulesets": [
30+
"prometheus"
31+
]
32+
}

0 commit comments

Comments
 (0)