jwtlint 0.2.0__tar.gz

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-0.2.0/LICENSE ADDED
@@ -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.
jwtlint-0.2.0/PKG-INFO ADDED
@@ -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
+ [![CI](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml/badge.svg)](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/jwtlint)](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
+ ![jwtlint flagging a token with a jku header, a kid path traversal and a weak secret](https://raw.githubusercontent.com/ReazGan/jwtlint/main/docs/screenshot.svg)
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,125 @@
1
+ # jwtlint
2
+
3
+ [![CI](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml/badge.svg)](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/jwtlint)](https://pypi.org/project/jwtlint/)
5
+
6
+ Offline static security analysis for JWTs (RFC 7519 / RFC 7515). Paste a
7
+ token from Burp, a log line or an `Authorization` header and get a list of
8
+ what's wrong with it. Parses the three base64url segments itself, no JWT
9
+ library in the analysis path, no network calls.
10
+
11
+ ![jwtlint flagging a token with a jku header, a kid path traversal and a weak secret](https://raw.githubusercontent.com/ReazGan/jwtlint/main/docs/screenshot.svg)
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install jwtlint
17
+ ```
18
+
19
+ or from source:
20
+
21
+ ```
22
+ git clone https://github.com/ReazGan/jwtlint
23
+ cd jwtlint
24
+ pip install -e .
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ $ jwtlint eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiaWF0IjoxNzg3MjY5OTY5LCJleHAiOjE3ODcyNzM1Njl9.0RVnIJ29GLfYPK3YfZNbLuhf_19F4nI71aeg0ZvZSr4
31
+ header
32
+ {
33
+ "alg": "HS256",
34
+ "typ": "JWT"
35
+ }
36
+ payload
37
+ {
38
+ "exp": 1787273569,
39
+ "iat": 1787269969,
40
+ "sub": "user-42"
41
+ }
42
+ jwtlint findings
43
+ ┏━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
44
+ ┃ sev ┃ check ┃ finding ┃
45
+ ┡━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
46
+ └──────────┴───────┴─────────┘
47
+
48
+ 0 findings
49
+ ```
50
+
51
+ An `alg: none` forgery attempt (see the screenshot above for the full run):
52
+
53
+ ```
54
+ $ jwtlint eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTcwMDAwMDAwMH0.
55
+ critical alg-none alg: 'none' — unsigned/forgeable token
56
+ high claims No exp (expiration) claim
57
+
58
+ 2 findings — critical: 1 high: 1
59
+ ```
60
+
61
+ Other flags:
62
+
63
+ ```
64
+ jwtlint --file token.txt # read the token from a file instead of argv
65
+ pbpaste | jwtlint # read from stdin ("Bearer ..." prefix is fine)
66
+ jwtlint <token> --expect-alg RS256 # flag RS-to-HS confusion if the token uses HS*
67
+ jwtlint <token> --crack # try the built-in weak-secret wordlist
68
+ jwtlint <token> --crack wordlist.txt # try a custom wordlist
69
+ jwtlint <token> --json # findings as JSON instead of a table
70
+ ```
71
+
72
+ Exit code is `1` if any finding is `critical` or `high` — usable as a CI gate.
73
+ Malformed input (wrong segment count, bad base64url, bad JSON) exits `2` with
74
+ a plain error message, not a stack trace.
75
+
76
+ ## Checks
77
+
78
+ - **alg-none** — header specifies `alg: none` (case-insensitively — `None`,
79
+ `NONE`, etc. are all caught), the classic unsigned-token forgery.
80
+ - **alg-confusion** — with `--expect-alg RS256` (or any RS/ES/PS algorithm),
81
+ flags a token that instead uses HS256/384/512 as a possible RS-to-HS key
82
+ confusion attack: a verifier that feeds its RSA/EC public key into HMAC
83
+ verification whenever the header says `alg: HS*` can be tricked into
84
+ accepting a token forged with that (public, therefore attacker-known) key
85
+ as the HMAC secret.
86
+ - **header-injection** — key-selection headers the attacker controls:
87
+ `jku` / `x5u` (verifier fetches the key from a URL, also an SSRF vector),
88
+ an embedded `jwk` (CVE-2018-0114 style self-signed tokens), an embedded
89
+ `x5c` chain, and `kid` values that look like path traversal
90
+ (`../../dev/null`), SQL or shell injection.
91
+ - **sensitive-data** — payload claims that look like passwords, API keys,
92
+ session ids or personal data (national id, IBAN, phone, card numbers with
93
+ a Luhn check). Payloads are base64url, not encrypted: anyone holding the
94
+ token can read them.
95
+ - **claims** — missing `exp` (high, token never expires), an already-expired
96
+ `exp` (info, just noted), missing `iat` (low), `nbf` earlier than `iat`
97
+ (low), and unreasonably long-lived tokens — `exp - iat` over a year by
98
+ default, configurable with `--max-lifetime-days`.
99
+ - **hmac-crack** (`--crack`, opt-in, fully offline) — for HS256/384/512
100
+ tokens, recomputes the HMAC signature for each line of a wordlist and
101
+ compares against the token's actual signature. Ships a built-in list of
102
+ ~50 common weak secrets used by default when `--crack` is passed with no
103
+ file.
104
+
105
+ ## In CI
106
+
107
+ Exit code `1` on critical/high makes it a one-line gate, e.g. to make sure
108
+ the tokens your test suite issues never carry secrets or skip `exp`:
109
+
110
+ ```yaml
111
+ - run: pip install jwtlint
112
+ - run: python scripts/issue_test_token.py | jwtlint
113
+ ```
114
+
115
+ ## Scope
116
+
117
+ A lean, non-interactive, single-purpose static analyzer — decode a token,
118
+ report what's wrong with it, exit. It doesn't talk to a server, doesn't try
119
+ exploits, and doesn't cover every JWT attack technique.
120
+ [`ticarpi/jwt_tool`](https://github.com/ticarpi/jwt_tool) is the heavier
121
+ reference tool if you need full interactive exploitation tooling.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,3 @@
1
+ """jwtlint — offline static analysis for JSON Web Tokens (RFC 7519/7515)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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
+ }
@@ -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