|
| 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 |
0 commit comments