Skip to content

Commit 06039a6

Browse files
[appsec] APPSEC-68400: API Security auth token schema tests (JWT & cookies) (#7219)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2151efa commit 06039a6

10 files changed

Lines changed: 226 additions & 0 deletions

File tree

manifests/cpp_nginx.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ manifest:
5050
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Body_env_var: missing_feature (APPSEC_API_SECURITY_NO_RESPONSE_BODY not supported)
5151
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Headers: v1.8.0
5252
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_on_Block: v1.8.0
53+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
5354
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: irrelevant (not applicable to proxies)
5455
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute: irrelevant (not applicable to proxies)
5556
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRouteMultiParamsInSegment: irrelevant (not applicable to proxies)

manifests/dotnet.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ manifest:
6363
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Body_env_var: missing_feature
6464
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Headers: v2.46.0
6565
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_on_Block: v3.26.3
66+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
6667
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: v3.45.0
6768
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute: missing_feature
6869
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRouteMultiParamsInSegment: missing_feature

manifests/golang.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ manifest:
8484
gin: v2.1.0-dev # Using response helper functions
8585
net-http: v2.1.0-dev # Using SDK-style
8686
uds-echo: v2.5.0 # Modified by easy win activation script
87+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
8788
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: v2.10.0-dev
8889
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute: missing_feature
8990
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRouteMultiParamsInSegment: missing_feature

manifests/java.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ manifest:
284284
akka-http: bug (APPSEC-56888)
285285
spring-boot-3-native: irrelevant (GraalVM. Tracing support only)
286286
vertx3: missing_feature
287+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
287288
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders:
288289
- weblog_declaration:
289290
"*": v1.63.0

manifests/nodejs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ manifest:
242242
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Body_env_var: missing_feature
243243
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Headers: *ref_4_21_0
244244
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_on_Block: missing_feature
245+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
245246
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: *ref_5_104_0
246247
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute: missing_feature
247248
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRouteMultiParamsInSegment: missing_feature

manifests/php.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ manifest:
9797
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Body_env_var: v1.6.2
9898
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_Headers: v0.94.0
9999
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_on_Block: v1.11.0-dev
100+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
100101
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: v1.21.0
101102
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute: missing_feature
102103
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRouteMultiParamsInSegment: missing_feature

manifests/python.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ manifest:
169169
"*": v2.1.0
170170
fastapi: v2.5.0
171171
tests/appsec/api_security/test_schemas.py::Test_Schema_Response_on_Block: v3.10.0.dev
172+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
172173
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: v4.11.0-rc1
173174
tests/appsec/api_security_testing/test_normalized_route.py::Test_NormalizedRoute:
174175
- weblog_declaration:

manifests/ruby.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ manifest:
231231
"*": v2.20.0-dev
232232
graphql23: missing_feature (no way to separate request paths)
233233
rack: missing_feature (performance impact of JSON parsing)
234+
tests/appsec/api_security/test_schemas_auth.py: missing_feature
234235
tests/appsec/api_security_testing/test_headers_collection.py::Test_SecurityTestingHeaders: # TODO: a lower version might be supported
235236
- weblog_declaration:
236237
'*': missing_feature
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Unless explicitly stated otherwise all files in this repository are licensed under the the Apache License Version 2.0.
2+
# This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
# Copyright 2021 Datadog, Inc.
4+
5+
"""API Security - authentication token schema extraction.
6+
7+
Covers the `extract-auth` post-processor (appsec-event-rules#289) which produces:
8+
- `_dd.appsec.s.req.jwt` from `server.request.jwt`
9+
- `_dd.appsec.s.req.cookies` from `server.request.cookies`
10+
11+
Test cases mirror Emile Spir's checklist on APPSEC-68400:
12+
* request has JWT, no cookie -> jwt schema generated
13+
* request has both JWT and cookie -> both schemas generated
14+
* sensitive value in JWT/cookie -> value never reported in the schema
15+
* JWT with a complex nested structure -> schema generated for nested fields
16+
17+
The "cookie, no JWT" case is omitted on purpose: request cookie schema extraction is produced by the
18+
baseline extract-content processor (covered by Test_Schema_Request_Cookies), not by this PR.
19+
"""
20+
21+
import base64
22+
import json
23+
24+
from utils import interfaces, rfc, scenarios, weblog, features
25+
from utils._weblog import HttpResponse
26+
27+
28+
RFC_LINK = "https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz"
29+
30+
31+
def get_schema(request: HttpResponse, address: str):
32+
"""Get api security schema from spans"""
33+
span = interfaces.library.get_root_span(request)
34+
meta = span.get("meta", {})
35+
return meta.get("_dd.appsec.s." + address)
36+
37+
38+
def get_meta_blob(request: HttpResponse) -> str:
39+
"""Return the whole root-span meta serialized, to check a value never leaks anywhere."""
40+
span = interfaces.library.get_root_span(request)
41+
return json.dumps(span.get("meta", {}))
42+
43+
44+
def _b64url(data: dict) -> str:
45+
raw = json.dumps(data, separators=(",", ":")).encode()
46+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
47+
48+
49+
def make_jwt(payload: dict, header: dict | None = None) -> str:
50+
"""Build an unsigned-but-well-formed JWT (header.payload.signature)."""
51+
header = header or {"alg": "HS256", "typ": "JWT"}
52+
return f"{_b64url(header)}.{_b64url(payload)}.{base64.urlsafe_b64encode(b'signature').rstrip(b'=').decode()}"
53+
54+
55+
# API Security schema type codes (see extract_schema generator)
56+
STR = 8
57+
INT = 4
58+
BOOL = 2
59+
60+
61+
def jwt_payload(schema: object) -> dict:
62+
"""Extract the decoded JWT claims (payload) from a `_dd.appsec.s.req.jwt` schema.
63+
64+
The JWT schema is split into header / payload / signature:
65+
[{"header": [{...}], "payload": [{...claims...}], "signature": [{"available": [2]}]}]
66+
"""
67+
assert isinstance(schema, list), f"jwt schema should be a list, got {schema}"
68+
root = schema[0]
69+
assert set(root) >= {"header", "payload", "signature"}, f"unexpected jwt schema shape: {root}"
70+
return root["payload"][0]
71+
72+
73+
def base_type(value: object) -> int:
74+
"""Return the scalar type code of a schema value, tolerating the single-element
75+
list wrapping that some frameworks use and ignoring trailing scanner metadata.
76+
77+
Accepts both the bare form `[8]` / `[8, {meta}]` and the wrapped form
78+
`[[[8]], {"len": 1}]` / `[[[8, {meta}]], {"len": 1}]`.
79+
"""
80+
node = value
81+
# unwrap the framework list form: [[[...]], {"len": n}] -> [...]
82+
while isinstance(node, list) and node and isinstance(node[0], list):
83+
node = node[0][0]
84+
assert isinstance(node, list), f"unexpected schema value: {value}"
85+
assert node, f"unexpected schema value: {value}"
86+
return node[0]
87+
88+
89+
SIMPLE_JWT = make_jwt({"sub": "1234567890", "name": "John Doe", "iat": 1516239022})
90+
COMPLEX_JWT = make_jwt(
91+
{
92+
"sub": "1234567890",
93+
"user": {"id": 42, "roles": ["admin", "user"], "profile": {"team": "appsec"}},
94+
"scopes": ["read", "write"],
95+
"iat": 1516239022,
96+
}
97+
)
98+
99+
100+
# NB: a "cookie, no JWT" case is intentionally not tested here: request cookie schema extraction
101+
# (_dd.appsec.s.req.cookies) is produced by the baseline extract-content processor and is already
102+
# covered by Test_Schema_Request_Cookies. It does not exercise the new extract-auth processor, so such
103+
# a test would pass even without the new rules. The res.cookies typo guard lives in
104+
# Test_Schema_Request_Jwt_And_Cookie instead, which is genuinely gated on the new feature.
105+
106+
107+
@rfc(RFC_LINK)
108+
@scenarios.appsec_api_security
109+
@features.auth_schemas
110+
class Test_Schema_Request_Jwt_No_Cookie:
111+
"""request has JWT, no cookie -> jwt schema generated"""
112+
113+
def setup_request_method(self):
114+
self.request = weblog.get("/tag_value/api_match_AS001/200", headers={"authorization": f"Bearer {SIMPLE_JWT}"})
115+
116+
def test_request_method(self):
117+
assert self.request.status_code == 200
118+
jwt = get_schema(self.request, "req.jwt")
119+
root = jwt[0]
120+
# claims are decoded under "payload", with their scalar types
121+
payload = jwt_payload(jwt)
122+
assert payload["sub"] == [STR], payload
123+
assert payload["name"] == [STR], payload
124+
assert payload["iat"] == [INT], payload
125+
# header is decoded too
126+
assert root["header"][0] == {"alg": [STR], "typ": [STR]}, root["header"]
127+
# the signature is only reported as a presence boolean, never its value
128+
assert root["signature"][0] == {"available": [BOOL]}, root["signature"]
129+
130+
131+
@rfc(RFC_LINK)
132+
@scenarios.appsec_api_security
133+
@features.auth_schemas
134+
class Test_Schema_Request_Jwt_And_Cookie:
135+
"""request has both JWT and cookie -> both schemas generated"""
136+
137+
def setup_request_method(self):
138+
self.request = weblog.get(
139+
"/tag_value/api_match_AS001/200",
140+
headers={"authorization": f"Bearer {SIMPLE_JWT}"},
141+
cookies={"session": "any_value"},
142+
)
143+
144+
def test_request_method(self):
145+
assert self.request.status_code == 200
146+
jwt = get_schema(self.request, "req.jwt")
147+
cookies = get_schema(self.request, "req.cookies")
148+
assert isinstance(cookies, list), f"_dd.appsec.s.req.cookies should be a list, got {cookies}"
149+
# tolerate frameworks that wrap cookie values as single-element lists
150+
assert set(cookies[0]) == {"session"}, cookies
151+
assert base_type(cookies[0]["session"]) == STR, cookies
152+
assert jwt_payload(jwt)["sub"] == [STR]
153+
# request cookies must land in req.cookies, not res.cookies (appsec-event-rules#289 typo guard)
154+
assert get_schema(self.request, "res.cookies") is None, "request cookies leaked into res.cookies (rules typo)"
155+
156+
157+
@rfc(RFC_LINK)
158+
@scenarios.appsec_api_security
159+
@features.auth_schemas
160+
class Test_Schema_Request_Auth_Sensitive_Value:
161+
"""sensitive value in JWT/cookie -> only the structure is reported, never the value itself"""
162+
163+
# values that must never appear in the schema (or anywhere in span meta)
164+
CARD = "5123456789123456"
165+
SSN = "123-45-6789"
166+
167+
def setup_request_method(self):
168+
jwt = make_jwt({"sub": "1234567890", "ssn": self.SSN})
169+
self.request = weblog.get(
170+
"/tag_value/api_match_AS001/200",
171+
headers={"authorization": f"Bearer {jwt}"},
172+
cookies={"mastercard": self.CARD},
173+
)
174+
175+
def test_request_method(self):
176+
assert self.request.status_code == 200
177+
cookies = get_schema(self.request, "req.cookies")
178+
# the SSN claim inside the JWT is reported as its type + PII classification, never the value:
179+
# scanners run on the decoded JWT content.
180+
assert jwt_payload(get_schema(self.request, "req.jwt"))["ssn"] == [STR, {"type": "us_ssn", "category": "pii"}]
181+
# the card number cookie keeps its key and string type (optionally annotated with payment
182+
# classification metadata by the scanners), but never the value itself
183+
assert base_type(cookies[0]["mastercard"]) == STR, cookies
184+
# and the raw sensitive values must not leak anywhere in the span meta
185+
blob = get_meta_blob(self.request)
186+
assert self.CARD not in blob, "credit card value leaked into span meta"
187+
assert self.SSN not in blob, "SSN value leaked into span meta"
188+
189+
190+
@rfc(RFC_LINK)
191+
@scenarios.appsec_api_security
192+
@features.auth_schemas
193+
class Test_Schema_Request_Jwt_Complex:
194+
"""JWT with a complex nested structure (maps with children, arrays) -> schema generated"""
195+
196+
def setup_request_method(self):
197+
self.request = weblog.get("/tag_value/api_match_AS001/200", headers={"authorization": f"Bearer {COMPLEX_JWT}"})
198+
199+
def test_request_method(self):
200+
assert self.request.status_code == 200
201+
payload = jwt_payload(get_schema(self.request, "req.jwt"))
202+
# array claim: element type + length metadata
203+
assert payload["scopes"] == [[[STR]], {"len": 2}], payload
204+
# nested object (map with children), including a child array and a deeper map
205+
user = payload["user"][0]
206+
assert user["id"] == [INT], user
207+
assert user["roles"] == [[[STR]], {"len": 2}], user
208+
assert user["profile"][0] == {"team": [STR]}, user

utils/_features.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3010,5 +3010,15 @@ def api_security_normalized_route(test_object):
30103010
"""
30113011
return _mark_test_object(test_object, feature_id=557, owner=_Owner.asm)
30123012

3013+
@staticmethod
3014+
def auth_schemas(test_object):
3015+
"""API Security - authentication token schema extraction: the extract-auth
3016+
processor reports schemas of JSON Web Tokens (`_dd.appsec.s.req.jwt`) and
3017+
cookies (`_dd.appsec.s.req.cookies`) when API Security is enabled.
3018+
3019+
https://feature-parity.us1.prod.dog/#/?feature=560
3020+
"""
3021+
return _mark_test_object(test_object, feature_id=560, owner=_Owner.asm)
3022+
30133023

30143024
features = _Features()

0 commit comments

Comments
 (0)