jwtlint 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jwtlint/__init__.py +3 -0
- jwtlint/checks/__init__.py +31 -0
- jwtlint/checks/alg_confusion.py +80 -0
- jwtlint/checks/alg_none.py +60 -0
- jwtlint/checks/base.py +30 -0
- jwtlint/checks/claims.py +146 -0
- jwtlint/checks/crack.py +100 -0
- jwtlint/checks/header_injection.py +137 -0
- jwtlint/checks/sensitive.py +101 -0
- jwtlint/cli.py +152 -0
- jwtlint/parser.py +101 -0
- jwtlint/wordlist.py +63 -0
- jwtlint-0.2.0.dist-info/METADATA +149 -0
- jwtlint-0.2.0.dist-info/RECORD +18 -0
- jwtlint-0.2.0.dist-info/WHEEL +5 -0
- jwtlint-0.2.0.dist-info/entry_points.txt +2 -0
- jwtlint-0.2.0.dist-info/licenses/LICENSE +21 -0
- jwtlint-0.2.0.dist-info/top_level.txt +1 -0
jwtlint/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Static analysis checks run against a parsed JWT.
|
|
2
|
+
|
|
3
|
+
Each check module exposes a `check(parsed, **options) -> list[Finding]`
|
|
4
|
+
function. `run_all` wires them together in a fixed order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from jwtlint.checks import alg_confusion, alg_none, claims, crack, header_injection, sensitive
|
|
10
|
+
from jwtlint.checks.base import Finding
|
|
11
|
+
from jwtlint.parser import ParsedToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_all(
|
|
15
|
+
parsed: ParsedToken,
|
|
16
|
+
expect_alg: str | None = None,
|
|
17
|
+
max_lifetime_seconds: int | None = None,
|
|
18
|
+
crack_wordlist: list[str] | None = None,
|
|
19
|
+
) -> list[dict]:
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
|
|
22
|
+
findings.extend(alg_none.check(parsed))
|
|
23
|
+
findings.extend(alg_confusion.check(parsed, expect_alg=expect_alg))
|
|
24
|
+
findings.extend(header_injection.check(parsed))
|
|
25
|
+
findings.extend(claims.check(parsed, max_lifetime_seconds=max_lifetime_seconds))
|
|
26
|
+
findings.extend(sensitive.check(parsed))
|
|
27
|
+
|
|
28
|
+
if crack_wordlist is not None:
|
|
29
|
+
findings.extend(crack.check(parsed, wordlist=crack_wordlist))
|
|
30
|
+
|
|
31
|
+
return [f.to_dict() for f in findings]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""RS-to-HS algorithm confusion check.
|
|
2
|
+
|
|
3
|
+
A static analyzer looking only at the token can't know what algorithm the
|
|
4
|
+
server actually expects — that's server-side configuration, not something
|
|
5
|
+
encoded in the token itself. `--expect-alg` lets the caller supply that
|
|
6
|
+
context (e.g. "I know this service is configured for RS256").
|
|
7
|
+
|
|
8
|
+
The attack: if a server verifies RS256/ES256 tokens using its RSA/EC
|
|
9
|
+
*public* key, and an attacker submits an HS256 token instead, some JWT
|
|
10
|
+
libraries will happily use whatever key material they're handed as the
|
|
11
|
+
HMAC secret. Since the RSA public key is, by definition, public, the
|
|
12
|
+
attacker can compute a valid HMAC signature over a forged token using the
|
|
13
|
+
public key as the secret — and the server verifies it as authentic. This
|
|
14
|
+
only matters if the verifier is naive enough to trust the `alg` from the
|
|
15
|
+
attacker-controlled header instead of pinning the expected algorithm.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from jwtlint.checks.base import Finding
|
|
21
|
+
from jwtlint.parser import ParsedToken
|
|
22
|
+
|
|
23
|
+
CHECK_NAME = "alg-confusion"
|
|
24
|
+
|
|
25
|
+
_HMAC_ALGS = {"HS256", "HS384", "HS512"}
|
|
26
|
+
_ASYMMETRIC_ALGS = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(parsed: ParsedToken, expect_alg: str | None = None) -> list[Finding]:
|
|
30
|
+
if not expect_alg:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
alg = parsed.header.get("alg")
|
|
34
|
+
if not isinstance(alg, str):
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
expect_alg_upper = expect_alg.upper()
|
|
38
|
+
alg_upper = alg.upper()
|
|
39
|
+
|
|
40
|
+
if alg_upper == expect_alg_upper:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
if alg_upper in _HMAC_ALGS and expect_alg_upper in _ASYMMETRIC_ALGS:
|
|
44
|
+
return [
|
|
45
|
+
Finding(
|
|
46
|
+
check=CHECK_NAME,
|
|
47
|
+
title=f"algorithm confusion risk: token uses {alg}, server expects {expect_alg}",
|
|
48
|
+
description=(
|
|
49
|
+
f"This token's header specifies alg={alg} (HMAC), but the "
|
|
50
|
+
f"server was told to expect {expect_alg} (RSA/EC, asymmetric). "
|
|
51
|
+
"This is the classic RS-to-HS confusion attack: if the "
|
|
52
|
+
"verifier passes its RSA/EC *public* key into an HMAC "
|
|
53
|
+
"verification routine whenever the header says alg=HS*, an "
|
|
54
|
+
"attacker can sign a forged token with HMAC-SHA256 using the "
|
|
55
|
+
"public key (which is, by definition, known to the attacker) "
|
|
56
|
+
"as the secret, and the server will treat it as validly "
|
|
57
|
+
"signed. A safe verifier must pin the expected algorithm "
|
|
58
|
+
"server-side and reject any token whose header claims a "
|
|
59
|
+
"different one, rather than trusting alg from attacker-"
|
|
60
|
+
"controlled input."
|
|
61
|
+
),
|
|
62
|
+
severity="critical",
|
|
63
|
+
evidence={"token_alg": alg, "expected_alg": expect_alg},
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
Finding(
|
|
69
|
+
check=CHECK_NAME,
|
|
70
|
+
title=f"algorithm mismatch: token uses {alg}, server expects {expect_alg}",
|
|
71
|
+
description=(
|
|
72
|
+
f"The token's alg ({alg}) does not match the expected algorithm "
|
|
73
|
+
f"({expect_alg}). A verifier that doesn't strictly pin/allowlist "
|
|
74
|
+
"the algorithm may accept tokens signed under a weaker or "
|
|
75
|
+
"unintended scheme."
|
|
76
|
+
),
|
|
77
|
+
severity="medium",
|
|
78
|
+
evidence={"token_alg": alg, "expected_alg": expect_alg},
|
|
79
|
+
)
|
|
80
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`alg: none` check (CVE-class: JWT algorithm-none forgery).
|
|
2
|
+
|
|
3
|
+
Some JWT libraries historically honored `"alg": "none"` (or accepted it
|
|
4
|
+
case-insensitively — `"None"`, `"NONE"`, `"nOnE"`) and skipped signature
|
|
5
|
+
verification entirely. An attacker who can set the header can then forge
|
|
6
|
+
any payload with an empty signature segment. This is check #1 in basically
|
|
7
|
+
every JWT security write-up because it's still found in the wild in custom
|
|
8
|
+
or misconfigured verifiers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from jwtlint.checks.base import Finding
|
|
14
|
+
from jwtlint.parser import ParsedToken
|
|
15
|
+
|
|
16
|
+
CHECK_NAME = "alg-none"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check(parsed: ParsedToken) -> list[Finding]:
|
|
20
|
+
alg = parsed.header.get("alg")
|
|
21
|
+
if not isinstance(alg, str):
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
if alg.lower() != "none":
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
findings = [
|
|
28
|
+
Finding(
|
|
29
|
+
check=CHECK_NAME,
|
|
30
|
+
title=f"alg: {alg!r} — unsigned/forgeable token",
|
|
31
|
+
description=(
|
|
32
|
+
"The header sets alg to a case-variant of 'none'. Libraries that "
|
|
33
|
+
"compare this case-insensitively (or that special-case 'none' "
|
|
34
|
+
"without validating the signature segment) will accept this token "
|
|
35
|
+
"with any payload and no valid signature — a classic auth bypass. "
|
|
36
|
+
"A safe verifier must reject 'none' outright and only accept an "
|
|
37
|
+
"algorithm it was explicitly configured to expect."
|
|
38
|
+
),
|
|
39
|
+
severity="critical",
|
|
40
|
+
evidence={"alg": alg},
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
if parsed.signature_segment.strip():
|
|
45
|
+
findings.append(
|
|
46
|
+
Finding(
|
|
47
|
+
check=CHECK_NAME,
|
|
48
|
+
title="alg: none token carries a non-empty signature segment",
|
|
49
|
+
description=(
|
|
50
|
+
"alg is 'none' but the signature segment is non-empty. This is "
|
|
51
|
+
"unusual — a spec-compliant 'none' token has an empty third "
|
|
52
|
+
"segment. The signature bytes present here are ignored by any "
|
|
53
|
+
"verifier that honors alg: none, so they provide no protection."
|
|
54
|
+
),
|
|
55
|
+
severity="low",
|
|
56
|
+
evidence={"alg": alg, "signature_segment_length": len(parsed.signature_segment)},
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return findings
|
jwtlint/checks/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared types for check modules.
|
|
2
|
+
|
|
3
|
+
Every check module in this package exposes a `check(parsed, **kwargs) ->
|
|
4
|
+
list[Finding]` function and returns findings shaped like `Finding.to_dict()`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
Severity = Literal["info", "low", "medium", "high", "critical"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Finding:
|
|
17
|
+
check: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"check": self.check,
|
|
26
|
+
"title": self.title,
|
|
27
|
+
"description": self.description,
|
|
28
|
+
"severity": self.severity,
|
|
29
|
+
"evidence": self.evidence,
|
|
30
|
+
}
|
jwtlint/checks/claims.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Standard claim hygiene checks (RFC 7519 section 4.1).
|
|
2
|
+
|
|
3
|
+
Covers: missing exp, already-expired exp (informational), missing iat, nbf
|
|
4
|
+
that looks wrong relative to iat, and unreasonably long-lived tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from jwtlint.checks.base import Finding
|
|
12
|
+
from jwtlint.parser import ParsedToken
|
|
13
|
+
|
|
14
|
+
CHECK_NAME = "claims"
|
|
15
|
+
|
|
16
|
+
# A year, as a round default for "this token lives suspiciously long."
|
|
17
|
+
DEFAULT_MAX_LIFETIME_SECONDS = 365 * 24 * 3600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _numeric_claim(payload: dict, name: str) -> float | None:
|
|
21
|
+
value = payload.get(name)
|
|
22
|
+
if isinstance(value, bool): # bool is a subclass of int, exclude explicitly
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
return float(value)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(parsed: ParsedToken, max_lifetime_seconds: int | None = None) -> list[Finding]:
|
|
30
|
+
threshold = max_lifetime_seconds if max_lifetime_seconds is not None else DEFAULT_MAX_LIFETIME_SECONDS
|
|
31
|
+
payload = parsed.payload
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
now = time.time()
|
|
34
|
+
|
|
35
|
+
exp = _numeric_claim(payload, "exp")
|
|
36
|
+
iat = _numeric_claim(payload, "iat")
|
|
37
|
+
nbf = _numeric_claim(payload, "nbf")
|
|
38
|
+
|
|
39
|
+
if "exp" not in payload:
|
|
40
|
+
findings.append(
|
|
41
|
+
Finding(
|
|
42
|
+
check=CHECK_NAME,
|
|
43
|
+
title="No exp (expiration) claim",
|
|
44
|
+
description=(
|
|
45
|
+
"The token has no exp claim, so it never expires by "
|
|
46
|
+
"definition. A stolen or leaked token remains valid "
|
|
47
|
+
"indefinitely unless revoked out-of-band. Every token "
|
|
48
|
+
"issued to a client should carry a bounded expiration."
|
|
49
|
+
),
|
|
50
|
+
severity="high",
|
|
51
|
+
evidence={},
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
elif exp is None:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding(
|
|
57
|
+
check=CHECK_NAME,
|
|
58
|
+
title="exp claim is not a valid NumericDate",
|
|
59
|
+
description=(
|
|
60
|
+
"exp is present but is not a number (RFC 7519 requires exp to "
|
|
61
|
+
"be a NumericDate — seconds since the Unix epoch). A verifier "
|
|
62
|
+
"that fails to validate this type may treat the token as "
|
|
63
|
+
"never expiring."
|
|
64
|
+
),
|
|
65
|
+
severity="medium",
|
|
66
|
+
evidence={"exp": payload.get("exp")},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
elif exp < now:
|
|
70
|
+
findings.append(
|
|
71
|
+
Finding(
|
|
72
|
+
check=CHECK_NAME,
|
|
73
|
+
title="Token is expired",
|
|
74
|
+
description=(
|
|
75
|
+
f"exp ({exp:.0f}) is in the past relative to the current time "
|
|
76
|
+
f"({now:.0f}). This isn't a vulnerability by itself — an "
|
|
77
|
+
"expired token should be rejected by any correct verifier — "
|
|
78
|
+
"it's just noted for context."
|
|
79
|
+
),
|
|
80
|
+
severity="info",
|
|
81
|
+
evidence={"exp": exp, "now": now, "expired_seconds_ago": now - exp},
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if "iat" not in payload:
|
|
86
|
+
findings.append(
|
|
87
|
+
Finding(
|
|
88
|
+
check=CHECK_NAME,
|
|
89
|
+
title="No iat (issued-at) claim",
|
|
90
|
+
description=(
|
|
91
|
+
"The token has no iat claim. iat isn't required by the spec, "
|
|
92
|
+
"but without it there's no record of when the token was "
|
|
93
|
+
"issued, which makes it harder to reason about token age, "
|
|
94
|
+
"detect replay of very old tokens, or audit issuance."
|
|
95
|
+
),
|
|
96
|
+
severity="low",
|
|
97
|
+
evidence={},
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
elif iat is None:
|
|
101
|
+
findings.append(
|
|
102
|
+
Finding(
|
|
103
|
+
check=CHECK_NAME,
|
|
104
|
+
title="iat claim is not a valid NumericDate",
|
|
105
|
+
description="iat is present but is not a number (RFC 7519 NumericDate).",
|
|
106
|
+
severity="low",
|
|
107
|
+
evidence={"iat": payload.get("iat")},
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if nbf is not None and iat is not None and nbf < iat:
|
|
112
|
+
findings.append(
|
|
113
|
+
Finding(
|
|
114
|
+
check=CHECK_NAME,
|
|
115
|
+
title="nbf predates iat",
|
|
116
|
+
description=(
|
|
117
|
+
f"nbf ({nbf:.0f}) is earlier than iat ({iat:.0f}) — the token "
|
|
118
|
+
"claims to be valid before it was issued. This is logically "
|
|
119
|
+
"inconsistent and may indicate a clock issue on the issuer or "
|
|
120
|
+
"a hand-crafted/forged token."
|
|
121
|
+
),
|
|
122
|
+
severity="low",
|
|
123
|
+
evidence={"nbf": nbf, "iat": iat},
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if exp is not None and iat is not None:
|
|
128
|
+
lifetime = exp - iat
|
|
129
|
+
if lifetime > threshold:
|
|
130
|
+
findings.append(
|
|
131
|
+
Finding(
|
|
132
|
+
check=CHECK_NAME,
|
|
133
|
+
title="Unusually long token lifetime",
|
|
134
|
+
description=(
|
|
135
|
+
f"exp - iat is {lifetime:.0f} seconds (~{lifetime / 86400:.0f} days), "
|
|
136
|
+
f"above the {threshold} second (~{threshold / 86400:.0f} day) threshold. "
|
|
137
|
+
"Long-lived tokens widen the window an attacker can use a "
|
|
138
|
+
"stolen token, and make revocation more important since "
|
|
139
|
+
"there's no natural expiry to rely on."
|
|
140
|
+
),
|
|
141
|
+
severity="medium",
|
|
142
|
+
evidence={"exp": exp, "iat": iat, "lifetime_seconds": lifetime, "threshold_seconds": threshold},
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return findings
|
jwtlint/checks/crack.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Offline HMAC secret brute force for HS256/HS384/HS512 tokens.
|
|
2
|
+
|
|
3
|
+
For each candidate key, recomputes HMAC-SHA{256,384,512} over the token's
|
|
4
|
+
signing input (`base64url(header) + "." + base64url(payload)`, per RFC 7515)
|
|
5
|
+
using Python's `hmac`/`hashlib` directly, and compares it to the token's
|
|
6
|
+
actual signature bytes with a constant-time comparison. This is entirely
|
|
7
|
+
local/offline — no network calls, no interaction with any server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
|
|
15
|
+
from jwtlint.checks.base import Finding
|
|
16
|
+
from jwtlint.parser import ParsedToken
|
|
17
|
+
|
|
18
|
+
CHECK_NAME = "hmac-crack"
|
|
19
|
+
|
|
20
|
+
_HASH_FOR_ALG = {
|
|
21
|
+
"HS256": hashlib.sha256,
|
|
22
|
+
"HS384": hashlib.sha384,
|
|
23
|
+
"HS512": hashlib.sha512,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def try_crack(parsed: ParsedToken, wordlist: list[str]) -> str | None:
|
|
28
|
+
"""Return the cracked secret, or None if nothing in the wordlist matches."""
|
|
29
|
+
alg = parsed.header.get("alg")
|
|
30
|
+
if not isinstance(alg, str):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
hash_fn = _HASH_FOR_ALG.get(alg.upper())
|
|
34
|
+
if hash_fn is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
target_sig = parsed.signature_bytes
|
|
38
|
+
if not target_sig:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
signing_input = parsed.signing_input
|
|
42
|
+
|
|
43
|
+
for candidate in wordlist:
|
|
44
|
+
candidate = candidate.strip()
|
|
45
|
+
if not candidate:
|
|
46
|
+
continue
|
|
47
|
+
computed = hmac.new(candidate.encode("utf-8"), signing_input, hash_fn).digest()
|
|
48
|
+
if hmac.compare_digest(computed, target_sig):
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check(parsed: ParsedToken, wordlist: list[str]) -> list[Finding]:
|
|
55
|
+
alg = parsed.header.get("alg")
|
|
56
|
+
if not isinstance(alg, str) or alg.upper() not in _HASH_FOR_ALG:
|
|
57
|
+
return [
|
|
58
|
+
Finding(
|
|
59
|
+
check=CHECK_NAME,
|
|
60
|
+
title="HMAC crack skipped: alg is not HS256/HS384/HS512",
|
|
61
|
+
description=(
|
|
62
|
+
f"--crack was passed but the token's alg is {alg!r}, which is "
|
|
63
|
+
"not an HMAC algorithm. There is no shared secret to brute "
|
|
64
|
+
"force for asymmetric (RS/ES/PS) or none-algorithm tokens."
|
|
65
|
+
),
|
|
66
|
+
severity="info",
|
|
67
|
+
evidence={"alg": alg},
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
cracked = try_crack(parsed, wordlist)
|
|
72
|
+
if cracked is not None:
|
|
73
|
+
return [
|
|
74
|
+
Finding(
|
|
75
|
+
check=CHECK_NAME,
|
|
76
|
+
title="HMAC secret cracked",
|
|
77
|
+
description=(
|
|
78
|
+
f"The signing secret was recovered from the wordlist: {cracked!r}. "
|
|
79
|
+
"Anyone with this token can now forge arbitrary tokens signed "
|
|
80
|
+
"with the same key. Rotate the secret and use a high-entropy, "
|
|
81
|
+
"randomly generated key of at least 256 bits."
|
|
82
|
+
),
|
|
83
|
+
severity="critical",
|
|
84
|
+
evidence={"alg": alg, "secret": cracked},
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
Finding(
|
|
90
|
+
check=CHECK_NAME,
|
|
91
|
+
title="HMAC secret not found in wordlist",
|
|
92
|
+
description=(
|
|
93
|
+
f"Tried {len(wordlist)} candidate secret(s) against this {alg} "
|
|
94
|
+
"token; none matched. This does not mean the secret is strong — "
|
|
95
|
+
"only that it isn't in the wordlist tried."
|
|
96
|
+
),
|
|
97
|
+
severity="info",
|
|
98
|
+
evidence={"alg": alg, "attempts": len(wordlist)},
|
|
99
|
+
)
|
|
100
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Key-selection header checks: jku, x5u, jwk, x5c, kid.
|
|
2
|
+
|
|
3
|
+
These headers tell the verifier *where to find the key*. They live in the
|
|
4
|
+
header, which the attacker controls, so a verifier that honours them without
|
|
5
|
+
an allowlist lets the attacker pick the key used to check their own forgery.
|
|
6
|
+
|
|
7
|
+
- jku / x5u: URL to fetch a JWK Set / X.509 chain from. Point it at an
|
|
8
|
+
attacker-hosted key and sign with the matching private key. Also an SSRF
|
|
9
|
+
primitive if the server fetches it blindly.
|
|
10
|
+
- jwk: the public key embedded inline (CVE-2018-0114 in node-jose). The
|
|
11
|
+
attacker embeds their own key and signs with it.
|
|
12
|
+
- x5c: same idea as jwk, with an inline certificate chain.
|
|
13
|
+
- kid: a free-form key id. Often used as a filename or DB lookup key, so
|
|
14
|
+
path traversal or SQL fragments here point at injection in the key lookup.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from jwtlint.checks.base import Finding
|
|
23
|
+
from jwtlint.parser import ParsedToken
|
|
24
|
+
|
|
25
|
+
CHECK_NAME = "header-injection"
|
|
26
|
+
|
|
27
|
+
_TRAVERSAL = re.compile(r"(\.\./|\.\.\\|^/|^[a-zA-Z]:\\|/dev/null|/etc/|/proc/)")
|
|
28
|
+
_SQL = re.compile(r"('|\"|--|;|\bunion\b|\bselect\b|\bor\s+1\s*=\s*1\b)", re.IGNORECASE)
|
|
29
|
+
_SHELL = re.compile(r"(\$\(|`|\|\||&&|\|)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check(parsed: ParsedToken) -> list[Finding]:
|
|
33
|
+
header = parsed.header
|
|
34
|
+
findings: list[Finding] = []
|
|
35
|
+
|
|
36
|
+
for name in ("jku", "x5u"):
|
|
37
|
+
value = header.get(name)
|
|
38
|
+
if value is not None:
|
|
39
|
+
findings.append(_url_header(name, value))
|
|
40
|
+
|
|
41
|
+
if "jwk" in header:
|
|
42
|
+
findings.append(
|
|
43
|
+
Finding(
|
|
44
|
+
check=CHECK_NAME,
|
|
45
|
+
title="embedded jwk header: token carries its own verification key",
|
|
46
|
+
description=(
|
|
47
|
+
"The header embeds a public key (jwk). A verifier that uses "
|
|
48
|
+
"this key to check the signature lets anyone sign a token with "
|
|
49
|
+
"their own private key and embed the matching public key "
|
|
50
|
+
"(CVE-2018-0114). Keys must come from server-side config, "
|
|
51
|
+
"never from the token."
|
|
52
|
+
),
|
|
53
|
+
severity="high",
|
|
54
|
+
evidence={"jwk": header["jwk"]},
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if "x5c" in header:
|
|
59
|
+
findings.append(
|
|
60
|
+
Finding(
|
|
61
|
+
check=CHECK_NAME,
|
|
62
|
+
title="embedded x5c header: token carries its own certificate chain",
|
|
63
|
+
description=(
|
|
64
|
+
"The header embeds an X.509 chain (x5c). Unless the verifier "
|
|
65
|
+
"validates that chain against a pinned trust anchor, an "
|
|
66
|
+
"attacker can supply a self-signed certificate and sign with "
|
|
67
|
+
"its key."
|
|
68
|
+
),
|
|
69
|
+
severity="medium",
|
|
70
|
+
evidence={"certs": len(header["x5c"]) if isinstance(header["x5c"], list) else None},
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if "kid" in header:
|
|
75
|
+
findings.extend(_kid(header["kid"]))
|
|
76
|
+
|
|
77
|
+
return findings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _url_header(name: str, value: Any) -> Finding:
|
|
81
|
+
return Finding(
|
|
82
|
+
check=CHECK_NAME,
|
|
83
|
+
title=f"{name} header present: key is fetched from a URL ({value})",
|
|
84
|
+
description=(
|
|
85
|
+
f"The header tells the verifier to fetch its key from {name}={value!r}. "
|
|
86
|
+
"If the server follows this URL without a strict allowlist, an "
|
|
87
|
+
"attacker can host their own key and sign forged tokens with it. "
|
|
88
|
+
"A blind fetch is also a server-side request forgery vector. "
|
|
89
|
+
"Ignore this header or pin it to a fixed, trusted URL."
|
|
90
|
+
),
|
|
91
|
+
severity="high",
|
|
92
|
+
evidence={name: value},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _kid(value: Any) -> list[Finding]:
|
|
97
|
+
if not isinstance(value, str):
|
|
98
|
+
return [
|
|
99
|
+
Finding(
|
|
100
|
+
check=CHECK_NAME,
|
|
101
|
+
title=f"kid is not a string ({type(value).__name__})",
|
|
102
|
+
description=(
|
|
103
|
+
"RFC 7515 defines kid as a string. A non-string kid can trip "
|
|
104
|
+
"type-confusion bugs in the key lookup."
|
|
105
|
+
),
|
|
106
|
+
severity="low",
|
|
107
|
+
evidence={"kid": value},
|
|
108
|
+
)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
hits = []
|
|
112
|
+
if _TRAVERSAL.search(value):
|
|
113
|
+
hits.append("path traversal")
|
|
114
|
+
if _SQL.search(value):
|
|
115
|
+
hits.append("SQL injection")
|
|
116
|
+
if _SHELL.search(value):
|
|
117
|
+
hits.append("command injection")
|
|
118
|
+
|
|
119
|
+
if not hits:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
kinds = ", ".join(hits)
|
|
123
|
+
return [
|
|
124
|
+
Finding(
|
|
125
|
+
check=CHECK_NAME,
|
|
126
|
+
title=f"suspicious kid value ({kinds}): {value!r}",
|
|
127
|
+
description=(
|
|
128
|
+
f"The kid header looks like a {kinds} payload. Servers often use "
|
|
129
|
+
"kid as a filename or database key when loading the signing "
|
|
130
|
+
"key. A classic trick is kid=../../dev/null so the key becomes "
|
|
131
|
+
"an empty string, then signing with an empty HMAC secret. "
|
|
132
|
+
"Treat kid as an opaque id and look it up in a fixed map."
|
|
133
|
+
),
|
|
134
|
+
severity="high",
|
|
135
|
+
evidence={"kid": value, "patterns": hits},
|
|
136
|
+
)
|
|
137
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Sensitive data in the payload.
|
|
2
|
+
|
|
3
|
+
A JWS payload is base64url, not encryption. Anyone holding the token (logs,
|
|
4
|
+
browser storage, proxies, referer headers) can read it. Flag claim names and
|
|
5
|
+
values that look like they shouldn't be there.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from jwtlint.checks.base import Finding
|
|
14
|
+
from jwtlint.parser import ParsedToken
|
|
15
|
+
|
|
16
|
+
CHECK_NAME = "sensitive-data"
|
|
17
|
+
|
|
18
|
+
# Matched against the claim name normalised to snake_case, on word edges, so
|
|
19
|
+
# "shipping" doesn't hit "pin" and "token_type" doesn't hit "token".
|
|
20
|
+
_SECRET_KEYS = re.compile(
|
|
21
|
+
r"(^|_)(pass|password|passwd|pwd|secret|api_?key|private_?key|access_?key|"
|
|
22
|
+
r"client_?secret|access_?token|refresh_?token|id_?token|session_?id|otp|pin|cvv)(_|$)"
|
|
23
|
+
)
|
|
24
|
+
_PII_KEYS = re.compile(
|
|
25
|
+
r"(^|_)(ssn|social_?security|tc_?kimlik|tckn|national_?id|passport|"
|
|
26
|
+
r"iban|card_?number|credit_?card|dob|birth_?date|date_?of_?birth|phone|phone_?number|address)(_|$)"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_VALUE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
30
|
+
("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
31
|
+
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
|
32
|
+
("private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
33
|
+
("card number", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
|
|
34
|
+
("IBAN", re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(parsed: ParsedToken) -> list[Finding]:
|
|
39
|
+
findings: list[Finding] = []
|
|
40
|
+
for path, raw_key, value in _walk(parsed.payload):
|
|
41
|
+
key = _snake(raw_key)
|
|
42
|
+
if _SECRET_KEYS.search(key) and _has_content(value):
|
|
43
|
+
findings.append(_finding(path, "secret-looking claim", "high"))
|
|
44
|
+
elif _PII_KEYS.search(key) and _has_content(value):
|
|
45
|
+
findings.append(_finding(path, "personal data claim", "medium"))
|
|
46
|
+
elif isinstance(value, str):
|
|
47
|
+
for label, pattern in _VALUE_PATTERNS:
|
|
48
|
+
if pattern.search(value) and (label != "card number" or _luhn(value)):
|
|
49
|
+
findings.append(_finding(path, f"{label} in claim value", "high"))
|
|
50
|
+
break
|
|
51
|
+
return findings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _finding(path: str, what: str, severity: str) -> Finding:
|
|
55
|
+
return Finding(
|
|
56
|
+
check=CHECK_NAME,
|
|
57
|
+
title=f"{what}: {path}",
|
|
58
|
+
description=(
|
|
59
|
+
f"Claim {path!r} looks sensitive. JWT payloads are only base64url "
|
|
60
|
+
"encoded, so anyone who sees the token can read it. Keep secrets "
|
|
61
|
+
"and personal data server-side, or use an encrypted JWE."
|
|
62
|
+
),
|
|
63
|
+
severity=severity,
|
|
64
|
+
evidence={"claim": path},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _walk(obj: Any, prefix: str = ""):
|
|
69
|
+
if isinstance(obj, dict):
|
|
70
|
+
for k, v in obj.items():
|
|
71
|
+
path = f"{prefix}.{k}" if prefix else str(k)
|
|
72
|
+
yield path, str(k), v
|
|
73
|
+
yield from _walk(v, path)
|
|
74
|
+
elif isinstance(obj, list):
|
|
75
|
+
for i, v in enumerate(obj):
|
|
76
|
+
yield from _walk(v, f"{prefix}[{i}]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _snake(key: str) -> str:
|
|
80
|
+
key = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key)
|
|
81
|
+
return re.sub(r"[^a-z0-9]+", "_", key.lower()).strip("_")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _has_content(value: Any) -> bool:
|
|
85
|
+
if isinstance(value, (dict, list)):
|
|
86
|
+
return False
|
|
87
|
+
return value not in (None, "", False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _luhn(text: str) -> bool:
|
|
91
|
+
digits = [int(c) for c in re.sub(r"\D", "", text)]
|
|
92
|
+
if not 13 <= len(digits) <= 19:
|
|
93
|
+
return False
|
|
94
|
+
total = 0
|
|
95
|
+
for i, d in enumerate(reversed(digits)):
|
|
96
|
+
if i % 2:
|
|
97
|
+
d *= 2
|
|
98
|
+
if d > 9:
|
|
99
|
+
d -= 9
|
|
100
|
+
total += d
|
|
101
|
+
return total % 10 == 0
|
jwtlint/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""jwtlint command-line entrypoint.
|
|
2
|
+
|
|
3
|
+
jwtlint <token> decode + analyze a token
|
|
4
|
+
jwtlint --file token.txt read the token from a file
|
|
5
|
+
echo <token> | jwtlint read the token from stdin
|
|
6
|
+
jwtlint <token> --expect-alg RS256 flag RS-to-HS confusion risk
|
|
7
|
+
jwtlint <token> --crack try the built-in weak-secret wordlist
|
|
8
|
+
jwtlint <token> --crack wordlist.txt try a custom wordlist
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.syntax import Syntax
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from jwtlint.checks import run_all
|
|
22
|
+
from jwtlint.parser import TokenParseError, parse
|
|
23
|
+
from jwtlint.wordlist import DEFAULT_WORDLIST
|
|
24
|
+
|
|
25
|
+
SEVERITY_STYLE = {
|
|
26
|
+
"critical": "bold white on red",
|
|
27
|
+
"high": "bold red",
|
|
28
|
+
"medium": "bold yellow",
|
|
29
|
+
"low": "cyan",
|
|
30
|
+
"info": "dim",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command()
|
|
39
|
+
@click.argument("token", required=False)
|
|
40
|
+
@click.option("--file", "file_path", type=click.Path(exists=True, dir_okay=False), help="Read the token from a file instead of the argument.")
|
|
41
|
+
@click.option("--expect-alg", "expect_alg", help="Algorithm the server is expected to use (e.g. RS256). Flags RS-to-HS confusion risk if the token uses HS*.")
|
|
42
|
+
@click.option("--crack", "crack_target", is_flag=False, flag_value="__default__", default=None, help="Try to brute force the HMAC secret. Pass a wordlist path, or nothing to use the built-in list.")
|
|
43
|
+
@click.option("--max-lifetime-days", "max_lifetime_days", type=int, default=None, help="Flag tokens with exp - iat longer than this many days (default 365).")
|
|
44
|
+
@click.option("--json", "json_out", is_flag=True, help="Print findings as JSON instead of a table.")
|
|
45
|
+
@click.version_option(package_name="jwtlint")
|
|
46
|
+
def main(
|
|
47
|
+
token: str | None,
|
|
48
|
+
file_path: str | None,
|
|
49
|
+
expect_alg: str | None,
|
|
50
|
+
crack_target: str | None,
|
|
51
|
+
max_lifetime_days: int | None,
|
|
52
|
+
json_out: bool,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Static, offline JWT security analyzer. Parses TOKEN and reports findings."""
|
|
55
|
+
token_text = _resolve_token(token, file_path)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
parsed = parse(token_text)
|
|
59
|
+
except TokenParseError as exc:
|
|
60
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
61
|
+
sys.exit(2)
|
|
62
|
+
|
|
63
|
+
wordlist = None
|
|
64
|
+
if crack_target is not None:
|
|
65
|
+
wordlist = DEFAULT_WORDLIST if crack_target == "__default__" else _load_wordlist(crack_target)
|
|
66
|
+
|
|
67
|
+
max_lifetime_seconds = max_lifetime_days * 86400 if max_lifetime_days is not None else None
|
|
68
|
+
|
|
69
|
+
findings = run_all(
|
|
70
|
+
parsed,
|
|
71
|
+
expect_alg=expect_alg,
|
|
72
|
+
max_lifetime_seconds=max_lifetime_seconds,
|
|
73
|
+
crack_wordlist=wordlist,
|
|
74
|
+
)
|
|
75
|
+
findings.sort(key=lambda f: SEVERITY_ORDER.get(f["severity"], 9))
|
|
76
|
+
|
|
77
|
+
if json_out:
|
|
78
|
+
console.print_json(json.dumps({"header": parsed.header, "payload": parsed.payload, "findings": findings}))
|
|
79
|
+
else:
|
|
80
|
+
_print_decoded(parsed)
|
|
81
|
+
_print_table(findings)
|
|
82
|
+
|
|
83
|
+
if any(f["severity"] in ("critical", "high") for f in findings):
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_token(token: str | None, file_path: str | None) -> str:
|
|
88
|
+
if file_path:
|
|
89
|
+
with open(file_path) as fh:
|
|
90
|
+
text = fh.read()
|
|
91
|
+
elif token and token != "-":
|
|
92
|
+
text = token
|
|
93
|
+
elif token == "-" or not sys.stdin.isatty():
|
|
94
|
+
text = sys.stdin.read()
|
|
95
|
+
else:
|
|
96
|
+
console.print("[bold red]error:[/] provide a token argument, --file, or pipe it on stdin")
|
|
97
|
+
sys.exit(2)
|
|
98
|
+
return _strip_auth_prefix(text.strip())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strip_auth_prefix(text: str) -> str:
|
|
102
|
+
"""Accept a pasted `Authorization: Bearer <jwt>` line as-is."""
|
|
103
|
+
if text.lower().startswith("authorization:"):
|
|
104
|
+
text = text.split(":", 1)[1].strip()
|
|
105
|
+
if text.lower().startswith("bearer "):
|
|
106
|
+
text = text[7:].strip()
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_wordlist(path: str) -> list[str]:
|
|
111
|
+
try:
|
|
112
|
+
with open(path) as fh:
|
|
113
|
+
return [line.rstrip("\n") for line in fh]
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
console.print(f"[bold red]error:[/] could not read wordlist {path!r}: {exc}")
|
|
116
|
+
sys.exit(2)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_decoded(parsed) -> None:
|
|
120
|
+
header_json = json.dumps(parsed.header, indent=2, sort_keys=True)
|
|
121
|
+
payload_json = json.dumps(parsed.payload, indent=2, sort_keys=True)
|
|
122
|
+
console.print("[bold]header[/]")
|
|
123
|
+
console.print(Syntax(header_json, "json", theme="ansi_dark", background_color="default"))
|
|
124
|
+
console.print("[bold]payload[/]")
|
|
125
|
+
console.print(Syntax(payload_json, "json", theme="ansi_dark", background_color="default"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_table(findings: list[dict]) -> None:
|
|
129
|
+
table = Table(title="jwtlint findings", show_lines=False, header_style="bold")
|
|
130
|
+
table.add_column("sev", width=8)
|
|
131
|
+
table.add_column("check")
|
|
132
|
+
table.add_column("finding")
|
|
133
|
+
|
|
134
|
+
for f in findings:
|
|
135
|
+
style = SEVERITY_STYLE.get(f["severity"], "")
|
|
136
|
+
table.add_row(
|
|
137
|
+
f"[{style}]{f['severity']}[/]",
|
|
138
|
+
f["check"],
|
|
139
|
+
f["title"],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
console.print(table)
|
|
143
|
+
|
|
144
|
+
counts: dict[str, int] = {}
|
|
145
|
+
for f in findings:
|
|
146
|
+
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
|
147
|
+
summary = " ".join(f"[{SEVERITY_STYLE.get(sev, '')}]{sev}: {n}[/]" for sev, n in counts.items())
|
|
148
|
+
console.print(f"\n{len(findings)} findings" + (f" — {summary}" if summary else "") + "\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
main()
|
jwtlint/parser.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Raw JWT structure parsing.
|
|
2
|
+
|
|
3
|
+
Deliberately does not use a JWT library. A security analyzer needs to reason
|
|
4
|
+
about the raw three-segment structure (including cases a permissive decoder
|
|
5
|
+
library would normalize away or refuse to touch, e.g. `alg: none`), so the
|
|
6
|
+
base64url decode and JSON parse are done by hand here per RFC 7519 / RFC 7515.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenParseError(Exception):
|
|
19
|
+
"""Raised when input cannot be parsed as a structurally valid JWT."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ParsedToken:
|
|
24
|
+
raw: str
|
|
25
|
+
header_segment: str
|
|
26
|
+
payload_segment: str
|
|
27
|
+
signature_segment: str
|
|
28
|
+
header: dict[str, Any]
|
|
29
|
+
payload: dict[str, Any]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def signing_input(self) -> bytes:
|
|
33
|
+
"""`base64url(header) + "." + base64url(payload)` — the exact bytes
|
|
34
|
+
that are signed/verified per RFC 7515."""
|
|
35
|
+
return f"{self.header_segment}.{self.payload_segment}".encode("ascii")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def signature_bytes(self) -> bytes:
|
|
39
|
+
return _b64url_decode(self.signature_segment) if self.signature_segment else b""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
43
|
+
"""base64url-decode a JWT segment, restoring the padding JWTs omit."""
|
|
44
|
+
padding_needed = (-len(segment)) % 4
|
|
45
|
+
padded = segment + ("=" * padding_needed)
|
|
46
|
+
try:
|
|
47
|
+
return base64.urlsafe_b64decode(padded)
|
|
48
|
+
except (binascii.Error, ValueError) as exc:
|
|
49
|
+
raise TokenParseError(f"invalid base64url encoding: {exc}") from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _b64url_decode_json(segment: str, part_name: str) -> dict[str, Any]:
|
|
53
|
+
raw_bytes = _b64url_decode(segment)
|
|
54
|
+
try:
|
|
55
|
+
text = raw_bytes.decode("utf-8")
|
|
56
|
+
except UnicodeDecodeError as exc:
|
|
57
|
+
raise TokenParseError(f"{part_name} is not valid UTF-8: {exc}") from exc
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(text)
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
raise TokenParseError(f"{part_name} is not valid JSON: {exc}") from exc
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise TokenParseError(f"{part_name} JSON must be an object, got {type(data).__name__}")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse(token: str) -> ParsedToken:
|
|
68
|
+
"""Split and decode a compact-serialization JWT into its three segments.
|
|
69
|
+
|
|
70
|
+
Raises TokenParseError with a clear, non-stack-trace message for any
|
|
71
|
+
malformed input: wrong segment count, bad base64url, bad JSON, or a
|
|
72
|
+
non-object header/payload.
|
|
73
|
+
"""
|
|
74
|
+
token = token.strip()
|
|
75
|
+
if not token:
|
|
76
|
+
raise TokenParseError("empty input")
|
|
77
|
+
|
|
78
|
+
segments = token.split(".")
|
|
79
|
+
if len(segments) != 3:
|
|
80
|
+
raise TokenParseError(
|
|
81
|
+
f"expected 3 dot-separated segments (header.payload.signature), got {len(segments)}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
header_segment, payload_segment, signature_segment = segments
|
|
85
|
+
|
|
86
|
+
if not header_segment:
|
|
87
|
+
raise TokenParseError("header segment is empty")
|
|
88
|
+
if not payload_segment:
|
|
89
|
+
raise TokenParseError("payload segment is empty")
|
|
90
|
+
|
|
91
|
+
header = _b64url_decode_json(header_segment, "header")
|
|
92
|
+
payload = _b64url_decode_json(payload_segment, "payload")
|
|
93
|
+
|
|
94
|
+
return ParsedToken(
|
|
95
|
+
raw=token,
|
|
96
|
+
header_segment=header_segment,
|
|
97
|
+
payload_segment=payload_segment,
|
|
98
|
+
signature_segment=signature_segment,
|
|
99
|
+
header=header,
|
|
100
|
+
payload=payload,
|
|
101
|
+
)
|
jwtlint/wordlist.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Built-in wordlist of common weak HMAC secrets used with `--crack` when no
|
|
2
|
+
wordlist file is given. Sourced from the usual suspects: default/placeholder
|
|
3
|
+
values shipped in tutorials and boilerplate, plus generically weak passwords
|
|
4
|
+
that show up in every leaked-credential list.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
DEFAULT_WORDLIST: list[str] = [
|
|
10
|
+
"secret",
|
|
11
|
+
"Secret",
|
|
12
|
+
"secret123",
|
|
13
|
+
"password",
|
|
14
|
+
"Password",
|
|
15
|
+
"password123",
|
|
16
|
+
"changeme",
|
|
17
|
+
"changethis",
|
|
18
|
+
"changeit",
|
|
19
|
+
"your-256-bit-secret",
|
|
20
|
+
"your-secret-key",
|
|
21
|
+
"your-secret-key-here",
|
|
22
|
+
"supersecret",
|
|
23
|
+
"super-secret",
|
|
24
|
+
"supersecretkey",
|
|
25
|
+
"mysecret",
|
|
26
|
+
"my-secret",
|
|
27
|
+
"jwtsecret",
|
|
28
|
+
"jwt-secret",
|
|
29
|
+
"jwt_secret",
|
|
30
|
+
"signingkey",
|
|
31
|
+
"signing-key",
|
|
32
|
+
"test",
|
|
33
|
+
"testing",
|
|
34
|
+
"dev",
|
|
35
|
+
"development",
|
|
36
|
+
"development-secret",
|
|
37
|
+
"development-only-secret",
|
|
38
|
+
"local",
|
|
39
|
+
"localhost",
|
|
40
|
+
"admin",
|
|
41
|
+
"administrator",
|
|
42
|
+
"letmein",
|
|
43
|
+
"qwerty",
|
|
44
|
+
"12345678",
|
|
45
|
+
"123456789",
|
|
46
|
+
"0123456789",
|
|
47
|
+
"abcdefgh",
|
|
48
|
+
"default",
|
|
49
|
+
"defaultsecret",
|
|
50
|
+
"key",
|
|
51
|
+
"apikey",
|
|
52
|
+
"api-key",
|
|
53
|
+
"api_key",
|
|
54
|
+
"token",
|
|
55
|
+
"authsecret",
|
|
56
|
+
"auth-secret",
|
|
57
|
+
"companyname",
|
|
58
|
+
"acme",
|
|
59
|
+
"acme-secret",
|
|
60
|
+
"insecure",
|
|
61
|
+
"notsecure",
|
|
62
|
+
"not-a-secret",
|
|
63
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jwtlint
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Offline static security analysis for JWTs.
|
|
5
|
+
Author: Baran Ayaztas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ReazGan/jwtlint
|
|
8
|
+
Project-URL: Issues, https://github.com/ReazGan/jwtlint/issues
|
|
9
|
+
Keywords: jwt,security,pentest,bug-bounty,appsec,static-analysis,jws
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: rich>=13.7
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# jwtlint
|
|
26
|
+
|
|
27
|
+
[](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/jwtlint/)
|
|
29
|
+
|
|
30
|
+
Offline static security analysis for JWTs (RFC 7519 / RFC 7515). Paste a
|
|
31
|
+
token from Burp, a log line or an `Authorization` header and get a list of
|
|
32
|
+
what's wrong with it. Parses the three base64url segments itself, no JWT
|
|
33
|
+
library in the analysis path, no network calls.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install jwtlint
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
or from source:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
git clone https://github.com/ReazGan/jwtlint
|
|
47
|
+
cd jwtlint
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ jwtlint eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiaWF0IjoxNzg3MjY5OTY5LCJleHAiOjE3ODcyNzM1Njl9.0RVnIJ29GLfYPK3YfZNbLuhf_19F4nI71aeg0ZvZSr4
|
|
55
|
+
header
|
|
56
|
+
{
|
|
57
|
+
"alg": "HS256",
|
|
58
|
+
"typ": "JWT"
|
|
59
|
+
}
|
|
60
|
+
payload
|
|
61
|
+
{
|
|
62
|
+
"exp": 1787273569,
|
|
63
|
+
"iat": 1787269969,
|
|
64
|
+
"sub": "user-42"
|
|
65
|
+
}
|
|
66
|
+
jwtlint findings
|
|
67
|
+
┏━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
|
|
68
|
+
┃ sev ┃ check ┃ finding ┃
|
|
69
|
+
┡━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
|
|
70
|
+
└──────────┴───────┴─────────┘
|
|
71
|
+
|
|
72
|
+
0 findings
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An `alg: none` forgery attempt (see the screenshot above for the full run):
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
$ jwtlint eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTcwMDAwMDAwMH0.
|
|
79
|
+
critical alg-none alg: 'none' — unsigned/forgeable token
|
|
80
|
+
high claims No exp (expiration) claim
|
|
81
|
+
|
|
82
|
+
2 findings — critical: 1 high: 1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Other flags:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
jwtlint --file token.txt # read the token from a file instead of argv
|
|
89
|
+
pbpaste | jwtlint # read from stdin ("Bearer ..." prefix is fine)
|
|
90
|
+
jwtlint <token> --expect-alg RS256 # flag RS-to-HS confusion if the token uses HS*
|
|
91
|
+
jwtlint <token> --crack # try the built-in weak-secret wordlist
|
|
92
|
+
jwtlint <token> --crack wordlist.txt # try a custom wordlist
|
|
93
|
+
jwtlint <token> --json # findings as JSON instead of a table
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Exit code is `1` if any finding is `critical` or `high` — usable as a CI gate.
|
|
97
|
+
Malformed input (wrong segment count, bad base64url, bad JSON) exits `2` with
|
|
98
|
+
a plain error message, not a stack trace.
|
|
99
|
+
|
|
100
|
+
## Checks
|
|
101
|
+
|
|
102
|
+
- **alg-none** — header specifies `alg: none` (case-insensitively — `None`,
|
|
103
|
+
`NONE`, etc. are all caught), the classic unsigned-token forgery.
|
|
104
|
+
- **alg-confusion** — with `--expect-alg RS256` (or any RS/ES/PS algorithm),
|
|
105
|
+
flags a token that instead uses HS256/384/512 as a possible RS-to-HS key
|
|
106
|
+
confusion attack: a verifier that feeds its RSA/EC public key into HMAC
|
|
107
|
+
verification whenever the header says `alg: HS*` can be tricked into
|
|
108
|
+
accepting a token forged with that (public, therefore attacker-known) key
|
|
109
|
+
as the HMAC secret.
|
|
110
|
+
- **header-injection** — key-selection headers the attacker controls:
|
|
111
|
+
`jku` / `x5u` (verifier fetches the key from a URL, also an SSRF vector),
|
|
112
|
+
an embedded `jwk` (CVE-2018-0114 style self-signed tokens), an embedded
|
|
113
|
+
`x5c` chain, and `kid` values that look like path traversal
|
|
114
|
+
(`../../dev/null`), SQL or shell injection.
|
|
115
|
+
- **sensitive-data** — payload claims that look like passwords, API keys,
|
|
116
|
+
session ids or personal data (national id, IBAN, phone, card numbers with
|
|
117
|
+
a Luhn check). Payloads are base64url, not encrypted: anyone holding the
|
|
118
|
+
token can read them.
|
|
119
|
+
- **claims** — missing `exp` (high, token never expires), an already-expired
|
|
120
|
+
`exp` (info, just noted), missing `iat` (low), `nbf` earlier than `iat`
|
|
121
|
+
(low), and unreasonably long-lived tokens — `exp - iat` over a year by
|
|
122
|
+
default, configurable with `--max-lifetime-days`.
|
|
123
|
+
- **hmac-crack** (`--crack`, opt-in, fully offline) — for HS256/384/512
|
|
124
|
+
tokens, recomputes the HMAC signature for each line of a wordlist and
|
|
125
|
+
compares against the token's actual signature. Ships a built-in list of
|
|
126
|
+
~50 common weak secrets used by default when `--crack` is passed with no
|
|
127
|
+
file.
|
|
128
|
+
|
|
129
|
+
## In CI
|
|
130
|
+
|
|
131
|
+
Exit code `1` on critical/high makes it a one-line gate, e.g. to make sure
|
|
132
|
+
the tokens your test suite issues never carry secrets or skip `exp`:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
- run: pip install jwtlint
|
|
136
|
+
- run: python scripts/issue_test_token.py | jwtlint
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Scope
|
|
140
|
+
|
|
141
|
+
A lean, non-interactive, single-purpose static analyzer — decode a token,
|
|
142
|
+
report what's wrong with it, exit. It doesn't talk to a server, doesn't try
|
|
143
|
+
exploits, and doesn't cover every JWT attack technique.
|
|
144
|
+
[`ticarpi/jwt_tool`](https://github.com/ticarpi/jwt_tool) is the heavier
|
|
145
|
+
reference tool if you need full interactive exploitation tooling.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
jwtlint/__init__.py,sha256=97FNMXtEELbOxFmWqt4YflQ-NjATEcX5SQON1bUV3Ao,102
|
|
2
|
+
jwtlint/cli.py,sha256=HKKw4sdiwb32bWpldax7OM6vOdDQ1_zZyinbpTlh0D8,5431
|
|
3
|
+
jwtlint/parser.py,sha256=Jj6WTYBoLwrgTI8l5wVTNBVMf3j9rgl0Qe1puD__aSQ,3321
|
|
4
|
+
jwtlint/wordlist.py,sha256=ZmeKmXMmHw4BqG3ukYqPOHQB4F1zPvBdX3CEuC2wuWI,1278
|
|
5
|
+
jwtlint/checks/__init__.py,sha256=fME76sMA_5xyZyDiF_vwWz3UNM8DAVcnYEEpjBIFNBk,1062
|
|
6
|
+
jwtlint/checks/alg_confusion.py,sha256=fFcvHOo2yo7BqyqWAGjtumBNvRY5fdlHauPZHke6txA,3456
|
|
7
|
+
jwtlint/checks/alg_none.py,sha256=lQxtmcsuC8hgt682dtRZ6EEjcEAtBIyn1dcsWvfJOEY,2292
|
|
8
|
+
jwtlint/checks/base.py,sha256=j4eqrs5RP8Je_mxFqgM8IdGQevZ1VsF8HyciyVVZB_s,785
|
|
9
|
+
jwtlint/checks/claims.py,sha256=XTwhpOda43KPUsKQUOrjajvLVoNafTk5Hi0-kslYkM8,5569
|
|
10
|
+
jwtlint/checks/crack.py,sha256=dvdNRWn2Zm36rPYPklw9U3vAvlGUfSesiwHxQ4QDY-Q,3358
|
|
11
|
+
jwtlint/checks/header_injection.py,sha256=xXZN2sjHebtJ54adAAhkdP1SCGBKxTnCNKcVrzX25Ag,5065
|
|
12
|
+
jwtlint/checks/sensitive.py,sha256=vsxlGVy02D2wf-Eqww1X5sjlVKScEiHhxN5RwRap6qk,3554
|
|
13
|
+
jwtlint-0.2.0.dist-info/licenses/LICENSE,sha256=SEdP_gAHRti-TMtzheXQfj2LIyT4MefoS7rOSb06f6g,1071
|
|
14
|
+
jwtlint-0.2.0.dist-info/METADATA,sha256=UVY4mLvHIGD9tOUrsp_DuKLTyECUHicqCU47awaO-dI,5704
|
|
15
|
+
jwtlint-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
jwtlint-0.2.0.dist-info/entry_points.txt,sha256=_f47NOkKl5PefboTv1_whkKZ7WUQGyrWkbSFO94QtYU,45
|
|
17
|
+
jwtlint-0.2.0.dist-info/top_level.txt,sha256=I20O2br316iyv9ClvYKvD1YIQiSWOaCk3Urq4HF-mjs,8
|
|
18
|
+
jwtlint-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Baran Ayaztaş
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jwtlint
|