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 ADDED
@@ -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
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
+ }
@@ -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
@@ -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
+ [![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,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jwtlint = jwtlint.cli:main
@@ -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