subcheck 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.
- subcheck/__init__.py +23 -0
- subcheck/__main__.py +5 -0
- subcheck/cli.py +80 -0
- subcheck/decoder.py +98 -0
- subcheck/policy.py +115 -0
- subcheck/py.typed +0 -0
- subcheck/report.py +106 -0
- subcheck/validator.py +64 -0
- subcheck-0.2.0.dist-info/METADATA +264 -0
- subcheck-0.2.0.dist-info/RECORD +13 -0
- subcheck-0.2.0.dist-info/WHEEL +4 -0
- subcheck-0.2.0.dist-info/entry_points.txt +2 -0
- subcheck-0.2.0.dist-info/licenses/LICENSE +21 -0
subcheck/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""subcheck: decode and validate GitHub Actions OIDC token claims."""
|
|
2
|
+
|
|
3
|
+
from .decoder import decode_claims, parse_github_sub
|
|
4
|
+
from .policy import ClaimRule, Policy, load_policy, load_policy_file
|
|
5
|
+
from .report import build_report, to_json, to_text
|
|
6
|
+
from .validator import Result, validate
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"decode_claims",
|
|
12
|
+
"parse_github_sub",
|
|
13
|
+
"ClaimRule",
|
|
14
|
+
"Policy",
|
|
15
|
+
"load_policy",
|
|
16
|
+
"load_policy_file",
|
|
17
|
+
"Result",
|
|
18
|
+
"validate",
|
|
19
|
+
"build_report",
|
|
20
|
+
"to_json",
|
|
21
|
+
"to_text",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
subcheck/__main__.py
ADDED
subcheck/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command-line interface for subcheck."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .decoder import decode_claims
|
|
12
|
+
from .policy import load_policy_file
|
|
13
|
+
from .report import build_report, to_json, to_text
|
|
14
|
+
from .validator import validate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_claims(args: argparse.Namespace) -> dict:
|
|
18
|
+
if args.token:
|
|
19
|
+
token = sys.stdin.read() if args.token == "-" else args.token # noqa: S105 # nosec B105 - a JWT, not a secret to store
|
|
20
|
+
return decode_claims(token)
|
|
21
|
+
if args.token_file:
|
|
22
|
+
return decode_claims(Path(args.token_file).read_text(encoding="utf-8"))
|
|
23
|
+
if args.claims:
|
|
24
|
+
return json.loads(Path(args.claims).read_text(encoding="utf-8"))
|
|
25
|
+
raise ValueError("provide one of --token, --token-file, or --claims")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="subcheck",
|
|
31
|
+
description="Decode GitHub Actions OIDC token claims and validate them "
|
|
32
|
+
"against an expected-claims policy.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
36
|
+
)
|
|
37
|
+
src = parser.add_argument_group("claims input (choose one)")
|
|
38
|
+
src.add_argument("--token", help="the OIDC JWT ('-' reads from stdin)")
|
|
39
|
+
src.add_argument("--token-file", help="path to a file containing the OIDC JWT")
|
|
40
|
+
src.add_argument("--claims", help="path to a JSON file of already-decoded claims")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--policy", help="path to a policy file (.yaml/.yml/.json); omit to only decode"
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--format", choices=["text", "json"], default="text",
|
|
46
|
+
help="output format (default: text)",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = _build_parser().parse_args(argv)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
claims = _load_claims(args)
|
|
56
|
+
except (ValueError, OSError, json.JSONDecodeError) as exc:
|
|
57
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
if not args.policy:
|
|
61
|
+
if args.format == "json":
|
|
62
|
+
print(json.dumps(claims, indent=2))
|
|
63
|
+
else:
|
|
64
|
+
for key, value in claims.items():
|
|
65
|
+
print(f"{key}: {value}")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
policy = load_policy_file(args.policy)
|
|
70
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
71
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
report = build_report(claims, validate(claims, policy))
|
|
75
|
+
print(to_json(report) if args.format == "json" else to_text(report))
|
|
76
|
+
return 0 if report["passed"] else 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
raise SystemExit(main())
|
subcheck/decoder.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Decode a GitHub Actions OIDC JSON Web Token into its claims.
|
|
2
|
+
|
|
3
|
+
This decodes the token payload for INSPECTION only. It does NOT verify the token
|
|
4
|
+
signature - verifying the signature against GitHub's JWKS is the cloud provider's
|
|
5
|
+
job at role-assumption time. Never trust these claims as an authentication control;
|
|
6
|
+
use them to catch a misconfigured trust policy before it ships.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
18
|
+
padding = "=" * (-len(segment) % 4)
|
|
19
|
+
return base64.urlsafe_b64decode(segment + padding)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_claims(token: str) -> dict:
|
|
23
|
+
"""Decode the claims (payload) of a JWT without verifying its signature."""
|
|
24
|
+
parts = token.strip().split(".")
|
|
25
|
+
if len(parts) != 3:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"not a JWT: expected 3 dot-separated segments, got {len(parts)}"
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
payload = _b64url_decode(parts[1])
|
|
31
|
+
except (ValueError, binascii.Error) as exc:
|
|
32
|
+
raise ValueError(f"could not base64url-decode the JWT payload: {exc}") from exc
|
|
33
|
+
try:
|
|
34
|
+
claims = json.loads(payload)
|
|
35
|
+
except json.JSONDecodeError as exc:
|
|
36
|
+
raise ValueError(f"JWT payload is not valid JSON: {exc}") from exc
|
|
37
|
+
if not isinstance(claims, dict):
|
|
38
|
+
raise ValueError("JWT payload did not decode to a JSON object")
|
|
39
|
+
return claims
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# repo:OWNER[@owner_id]/REPO[@repo_id]: ... -- the immutable format appends a numeric
|
|
43
|
+
# owner/repo ID (mandatory for repos created, renamed, or transferred after 2026-07-15).
|
|
44
|
+
# Owner/repo names exclude the '/', '@', ':' delimiters. Mirrors the subvectors subject
|
|
45
|
+
# grammar so the two agree on what a concrete subject decodes to.
|
|
46
|
+
_SUB_RE = re.compile(
|
|
47
|
+
r"^repo:"
|
|
48
|
+
r"(?P<owner>[^/@:]+)(?:@(?P<owner_id>\d+))?"
|
|
49
|
+
r"/"
|
|
50
|
+
r"(?P<repo>[^/@:]+)(?:@(?P<repo_id>\d+))?"
|
|
51
|
+
r"(?::(?P<context>.*))?$"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_github_sub(sub: str) -> dict:
|
|
56
|
+
"""Best-effort parse of the GitHub Actions ``sub`` claim into its components.
|
|
57
|
+
|
|
58
|
+
Handles both the classic name-based format and the immutable format that appends
|
|
59
|
+
numeric owner/repo IDs (``repo:owner@123/repo@456:...``). ``format`` is ``"immutable"``
|
|
60
|
+
only when BOTH IDs are present (the documented grammar always carries ``@ID`` on both
|
|
61
|
+
segments), ``"legacy"`` when neither is, and ``"malformed"`` when exactly one is —
|
|
62
|
+
a shape GitHub never mints, so it signals a hand-edited or half-migrated value.
|
|
63
|
+
``repository`` is always the ``owner/repo`` names.
|
|
64
|
+
|
|
65
|
+
Examples::
|
|
66
|
+
|
|
67
|
+
repo:acme/api:ref:refs/heads/main -> repository, context=ref, ref, format=legacy
|
|
68
|
+
repo:acme/api:environment:production -> repository, context=environment, environment
|
|
69
|
+
repo:acme@1/api@2:ref:refs/heads/main -> repository, repository_id, ..., format=immutable
|
|
70
|
+
repo:acme/api@2:ref:refs/heads/main -> format=malformed (only one ID present)
|
|
71
|
+
repo:acme/api:pull_request -> repository, context=pull_request
|
|
72
|
+
"""
|
|
73
|
+
out: dict = {"raw": sub}
|
|
74
|
+
m = _SUB_RE.match(sub)
|
|
75
|
+
if m is None:
|
|
76
|
+
return out
|
|
77
|
+
owner, repo = m.group("owner"), m.group("repo")
|
|
78
|
+
owner_id, repo_id = m.group("owner_id"), m.group("repo_id")
|
|
79
|
+
out["repository_owner"] = owner
|
|
80
|
+
out["repository"] = f"{owner}/{repo}"
|
|
81
|
+
if owner_id is not None:
|
|
82
|
+
out["repository_owner_id"] = owner_id
|
|
83
|
+
if repo_id is not None:
|
|
84
|
+
out["repository_id"] = repo_id
|
|
85
|
+
if owner_id and repo_id:
|
|
86
|
+
out["format"] = "immutable"
|
|
87
|
+
elif owner_id or repo_id:
|
|
88
|
+
out["format"] = "malformed" # GitHub always emits @ID on both segments, or neither
|
|
89
|
+
else:
|
|
90
|
+
out["format"] = "legacy"
|
|
91
|
+
context = m.group("context")
|
|
92
|
+
if not context:
|
|
93
|
+
return out
|
|
94
|
+
kind, _, value = context.partition(":")
|
|
95
|
+
out["context"] = kind
|
|
96
|
+
if value:
|
|
97
|
+
out[kind] = value
|
|
98
|
+
return out
|
subcheck/policy.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Load an expected-claims policy and represent it as claim rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Trust-boundary anchors are high severity; contextual claims default to medium.
|
|
10
|
+
CLAIM_SEVERITY = {
|
|
11
|
+
"iss": "high",
|
|
12
|
+
"aud": "high",
|
|
13
|
+
"sub": "high",
|
|
14
|
+
"repository": "high",
|
|
15
|
+
"repository_owner": "high",
|
|
16
|
+
"repository_id": "high", # immutable trust anchors (survive rename/transfer)
|
|
17
|
+
"repository_owner_id": "high",
|
|
18
|
+
# the only claim constraining WHICH workflow code minted the token; AWS accepts it as an
|
|
19
|
+
# alternative identity-provider control to sub.
|
|
20
|
+
"job_workflow_ref": "high",
|
|
21
|
+
"ref": "medium",
|
|
22
|
+
"environment": "medium",
|
|
23
|
+
"runner_environment": "medium",
|
|
24
|
+
"actor": "medium",
|
|
25
|
+
}
|
|
26
|
+
DEFAULT_SEVERITY = "medium"
|
|
27
|
+
|
|
28
|
+
_ALLOWED_KEYS = {"equals", "in", "matches", "glob", "required"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ClaimRule:
|
|
33
|
+
name: str
|
|
34
|
+
equals: str | None = None
|
|
35
|
+
one_of: list | None = None
|
|
36
|
+
matches: str | None = None # regex, applied with re.search
|
|
37
|
+
glob: str | None = None # fnmatch-style pattern
|
|
38
|
+
required: bool = True
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def severity(self) -> str:
|
|
42
|
+
return CLAIM_SEVERITY.get(self.name, DEFAULT_SEVERITY)
|
|
43
|
+
|
|
44
|
+
def describe(self) -> str:
|
|
45
|
+
if self.equals is not None:
|
|
46
|
+
return f"equals {self.equals!r}"
|
|
47
|
+
if self.one_of is not None:
|
|
48
|
+
return f"one of {self.one_of!r}"
|
|
49
|
+
if self.matches is not None:
|
|
50
|
+
return f"matches /{self.matches}/"
|
|
51
|
+
if self.glob is not None:
|
|
52
|
+
return f"glob {self.glob!r}"
|
|
53
|
+
return "present"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Policy:
|
|
58
|
+
rules: list = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_policy(data: dict) -> Policy:
|
|
62
|
+
"""Build a Policy from a parsed mapping (see the README for the schema)."""
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise ValueError("policy must be a mapping/object")
|
|
65
|
+
rules: list = []
|
|
66
|
+
if "issuer" in data:
|
|
67
|
+
rules.append(ClaimRule(name="iss", equals=str(data["issuer"])))
|
|
68
|
+
if "audience" in data:
|
|
69
|
+
rules.append(ClaimRule(name="aud", equals=str(data["audience"])))
|
|
70
|
+
claims = data.get("claims") or {}
|
|
71
|
+
if not isinstance(claims, dict):
|
|
72
|
+
raise ValueError("policy 'claims' must be a mapping of claim -> rule")
|
|
73
|
+
for name, spec in claims.items():
|
|
74
|
+
rules.append(_rule_from_spec(name, spec))
|
|
75
|
+
if not rules:
|
|
76
|
+
raise ValueError("policy is empty: define 'issuer', 'audience', or 'claims'")
|
|
77
|
+
return Policy(rules=rules)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rule_from_spec(name: str, spec) -> ClaimRule:
|
|
81
|
+
if isinstance(spec, str):
|
|
82
|
+
return ClaimRule(name=name, equals=spec)
|
|
83
|
+
if isinstance(spec, list):
|
|
84
|
+
return ClaimRule(name=name, one_of=list(spec))
|
|
85
|
+
if isinstance(spec, dict):
|
|
86
|
+
unknown = set(spec) - _ALLOWED_KEYS
|
|
87
|
+
if unknown:
|
|
88
|
+
raise ValueError(f"claim {name!r}: unknown rule keys {sorted(unknown)}")
|
|
89
|
+
return ClaimRule(
|
|
90
|
+
name=name,
|
|
91
|
+
equals=spec.get("equals"),
|
|
92
|
+
one_of=spec.get("in"),
|
|
93
|
+
matches=spec.get("matches"),
|
|
94
|
+
glob=spec.get("glob"),
|
|
95
|
+
required=bool(spec.get("required", True)),
|
|
96
|
+
)
|
|
97
|
+
raise ValueError(f"claim {name!r}: rule must be a string, list, or mapping")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_policy_file(path: str | Path) -> Policy:
|
|
101
|
+
"""Load a policy from a .json, .yaml, or .yml file."""
|
|
102
|
+
p = Path(path)
|
|
103
|
+
text = p.read_text(encoding="utf-8")
|
|
104
|
+
if p.suffix.lower() in {".yaml", ".yml"}:
|
|
105
|
+
try:
|
|
106
|
+
import yaml
|
|
107
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"PyYAML is required to load YAML policies (pip install PyYAML), "
|
|
110
|
+
"or use a .json policy instead"
|
|
111
|
+
) from exc
|
|
112
|
+
data = yaml.safe_load(text)
|
|
113
|
+
else:
|
|
114
|
+
data = json.loads(text)
|
|
115
|
+
return load_policy(data)
|
subcheck/py.typed
ADDED
|
File without changes
|
subcheck/report.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Assemble and format the inspection report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
|
|
8
|
+
from .decoder import parse_github_sub
|
|
9
|
+
from .validator import FAIL, MISSING, PASS, Result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_report(claims: dict, results: list[Result]) -> dict:
|
|
13
|
+
passed = all(r.status == PASS for r in results)
|
|
14
|
+
return {
|
|
15
|
+
"passed": passed,
|
|
16
|
+
"summary": {
|
|
17
|
+
"total": len(results),
|
|
18
|
+
"pass": sum(r.status == PASS for r in results),
|
|
19
|
+
"fail": sum(r.status == FAIL for r in results),
|
|
20
|
+
"missing": sum(r.status == MISSING for r in results),
|
|
21
|
+
},
|
|
22
|
+
"notes": _advisories(claims, results),
|
|
23
|
+
"results": [asdict(r) for r in results],
|
|
24
|
+
"claims": claims,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
GITHUB_ISSUER = "https://token.actions.githubusercontent.com"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _advisories(claims: dict, results: list[Result]) -> list[str]:
|
|
32
|
+
"""Non-gating hints, chiefly about the 2026 immutable subject-claims migration.
|
|
33
|
+
|
|
34
|
+
Immutable subject claims are a github.com-only feature, so the migration hints are
|
|
35
|
+
suppressed for any other issuer (notably GitHub Enterprise Server, which keeps the
|
|
36
|
+
mutable name-based format and uses an https://HOSTNAME/_services/token issuer).
|
|
37
|
+
"""
|
|
38
|
+
sub = claims.get("sub")
|
|
39
|
+
if not isinstance(sub, str):
|
|
40
|
+
return []
|
|
41
|
+
iss = claims.get("iss")
|
|
42
|
+
if isinstance(iss, str) and iss != GITHUB_ISSUER:
|
|
43
|
+
return []
|
|
44
|
+
parsed = parse_github_sub(sub)
|
|
45
|
+
fmt = parsed.get("format")
|
|
46
|
+
if fmt is None:
|
|
47
|
+
return []
|
|
48
|
+
sub_result = next((r for r in results if r.claim == "sub"), None)
|
|
49
|
+
name_based_sub_pin = sub_result is not None and "@" not in str(sub_result.expected)
|
|
50
|
+
notes: list[str] = []
|
|
51
|
+
if fmt == "immutable":
|
|
52
|
+
oid, rid = parsed.get("repository_owner_id"), parsed.get("repository_id")
|
|
53
|
+
notes.append(
|
|
54
|
+
f"sub uses the immutable format (repository_owner_id={oid}, repository_id={rid}); "
|
|
55
|
+
"pin these numeric IDs in the cloud trust policy rather than mutable owner/repo names."
|
|
56
|
+
)
|
|
57
|
+
if sub_result is not None and sub_result.status == FAIL and name_based_sub_pin:
|
|
58
|
+
notes.append(
|
|
59
|
+
"the sub check failed while the token is immutable-format and the expected "
|
|
60
|
+
"pattern looks name-based; update the expected sub, or pin repository_id / "
|
|
61
|
+
"repository_owner_id instead."
|
|
62
|
+
)
|
|
63
|
+
elif fmt == "malformed":
|
|
64
|
+
notes.append(
|
|
65
|
+
"sub carries an owner/repo ID on only one segment; GitHub always emits '@id' on "
|
|
66
|
+
"both or neither, so this value looks hand-edited or half-migrated."
|
|
67
|
+
)
|
|
68
|
+
elif fmt == "legacy" and name_based_sub_pin:
|
|
69
|
+
notes.append(
|
|
70
|
+
"sub is pinned by name; when this repo adopts the immutable format the sub becomes "
|
|
71
|
+
"'owner@id/repo@id:...' and this pattern stops matching - pin repository_id / "
|
|
72
|
+
"repository_owner_id to stay durable. Adoption is automatic for repos created, "
|
|
73
|
+
"renamed, or transferred after 2026-07-15, but any repo can be switched on sooner "
|
|
74
|
+
"via the org-level or repo-level immutable-subject setting, so do not infer the "
|
|
75
|
+
"format from the repo's age."
|
|
76
|
+
)
|
|
77
|
+
return notes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def to_json(report: dict) -> str:
|
|
81
|
+
return json.dumps(report, indent=2)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ICON = {PASS: "[+]", FAIL: "[-]", MISSING: "[!]"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_text(report: dict) -> str:
|
|
88
|
+
summary = report["summary"]
|
|
89
|
+
verdict = "PASS" if report["passed"] else "FAIL"
|
|
90
|
+
lines = [
|
|
91
|
+
f"OIDC claim inspection: {verdict} "
|
|
92
|
+
f"({summary['pass']} pass, {summary['fail']} fail, {summary['missing']} missing)",
|
|
93
|
+
"",
|
|
94
|
+
]
|
|
95
|
+
width = max((len(r["claim"]) for r in report["results"]), default=5)
|
|
96
|
+
for r in report["results"]:
|
|
97
|
+
icon = _ICON.get(r["status"], "[?]")
|
|
98
|
+
lines.append(
|
|
99
|
+
f" {icon} {r['claim']:<{width}} {r['severity']:<6} {r['message']}"
|
|
100
|
+
)
|
|
101
|
+
notes = report.get("notes") or []
|
|
102
|
+
if notes:
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append("Notes:")
|
|
105
|
+
lines.extend(f" [i] {note}" for note in notes)
|
|
106
|
+
return "\n".join(lines)
|
subcheck/validator.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Validate decoded claims against a policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .policy import ClaimRule, Policy
|
|
10
|
+
|
|
11
|
+
PASS = "PASS" # noqa: S105 # nosec B105 - a status constant, not a secret
|
|
12
|
+
FAIL = "FAIL"
|
|
13
|
+
MISSING = "MISSING"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Result:
|
|
18
|
+
claim: str
|
|
19
|
+
status: str
|
|
20
|
+
severity: str
|
|
21
|
+
expected: str
|
|
22
|
+
actual: object
|
|
23
|
+
message: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _matches(rule: ClaimRule, value) -> bool:
|
|
27
|
+
text = str(value)
|
|
28
|
+
if rule.equals is not None and value != rule.equals:
|
|
29
|
+
return False
|
|
30
|
+
if rule.one_of is not None and value not in rule.one_of:
|
|
31
|
+
return False
|
|
32
|
+
if rule.matches is not None and re.search(rule.matches, text) is None:
|
|
33
|
+
return False
|
|
34
|
+
if rule.glob is not None and not fnmatch.fnmatchcase(text, rule.glob):
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate(claims: dict, policy: Policy) -> list[Result]:
|
|
40
|
+
"""Check each policy rule against the claims and return one Result per rule."""
|
|
41
|
+
results: list[Result] = []
|
|
42
|
+
for rule in policy.rules:
|
|
43
|
+
expected = rule.describe()
|
|
44
|
+
if rule.name not in claims:
|
|
45
|
+
status = MISSING if rule.required else PASS
|
|
46
|
+
note = (
|
|
47
|
+
f"claim {rule.name!r} is required but absent"
|
|
48
|
+
if rule.required
|
|
49
|
+
else f"claim {rule.name!r} absent (optional)"
|
|
50
|
+
)
|
|
51
|
+
results.append(Result(rule.name, status, rule.severity, expected, None, note))
|
|
52
|
+
continue
|
|
53
|
+
actual = claims[rule.name]
|
|
54
|
+
if _matches(rule, actual):
|
|
55
|
+
results.append(
|
|
56
|
+
Result(rule.name, PASS, rule.severity, expected, actual,
|
|
57
|
+
f"claim {rule.name!r} satisfies {expected}")
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
results.append(
|
|
61
|
+
Result(rule.name, FAIL, rule.severity, expected, actual,
|
|
62
|
+
f"claim {rule.name!r}={actual!r} does not satisfy {expected}")
|
|
63
|
+
)
|
|
64
|
+
return results
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: subcheck
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Decode GitHub Actions OIDC token claims and validate them against an expected-claims policy - a CI gate against over-broad cloud trust.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dashtid/subcheck
|
|
6
|
+
Project-URL: Related, https://github.com/Dashtid/subvectors
|
|
7
|
+
Author: David Dashti
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aws-iam,cicd-security,github-actions,least-privilege,oidc,supply-chain-security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: bandit>=1.7; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
28
|
+
Requires-Dist: types-pyyaml>=6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# subcheck
|
|
32
|
+
|
|
33
|
+
Decode a GitHub Actions **OIDC token's claims** and check them against an expected-claims
|
|
34
|
+
policy — so a workflow fails *before* an over-broad cloud trust policy lets the wrong branch,
|
|
35
|
+
workflow, or trigger assume your role.
|
|
36
|
+
|
|
37
|
+
*Named for the claim that decides everything — `sub`. A focused sibling of
|
|
38
|
+
[subvectors](https://github.com/Dashtid/subvectors), the conformance test-vector suite (an "answer
|
|
39
|
+
key") that grades whether those trust conditions are well-formed, matching, and safe.*
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
$ subcheck --claims examples/claims-pull-request.json --policy examples/policy.json
|
|
43
|
+
OIDC claim inspection: FAIL (5 pass, 1 fail, 1 missing)
|
|
44
|
+
|
|
45
|
+
[+] iss high claim 'iss' satisfies equals 'https://token.actions.githubusercontent.com'
|
|
46
|
+
[+] aud high claim 'aud' satisfies equals 'sts.amazonaws.com'
|
|
47
|
+
[+] repository high claim 'repository' satisfies equals 'acme/payments-api'
|
|
48
|
+
[+] repository_owner high claim 'repository_owner' satisfies equals 'acme'
|
|
49
|
+
[-] sub high claim 'sub'='repo:acme/payments-api:pull_request' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
|
|
50
|
+
[!] environment medium claim 'environment' is required but absent
|
|
51
|
+
[+] runner_environment medium claim 'runner_environment' satisfies equals 'github-hosted'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exit code is non-zero on any finding, so the command drops straight into a CI step as a gate.
|
|
55
|
+
|
|
56
|
+
## Why
|
|
57
|
+
|
|
58
|
+
GitHub Actions can authenticate to AWS/Azure/GCP with a short-lived **OIDC token** instead of a
|
|
59
|
+
long-lived secret. The cloud side (e.g. an AWS IAM role's trust policy) decides *which* tokens
|
|
60
|
+
may assume the role by matching claims — above all `sub`
|
|
61
|
+
(`repo:org/repo:ref:refs/heads/main`, `...:environment:production`, `...:pull_request`, …).
|
|
62
|
+
|
|
63
|
+
The classic mistake is a trust condition that's too loose — a wildcard `sub`, a missing
|
|
64
|
+
condition, or `...:sub` allowed for `repo:org/*` — so a token minted by something you never
|
|
65
|
+
intended can assume a privileged role. This tool pins down exactly which claims you *expect* and
|
|
66
|
+
flags the moment a token doesn't match.
|
|
67
|
+
|
|
68
|
+
> **Who can actually mint such a token.** Not a fork's pull request: for `pull_request` runs from
|
|
69
|
+
> a fork GitHub downgrades `id-token: write` and never injects
|
|
70
|
+
> `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, so a fork cannot obtain a token for the upstream repo. The
|
|
71
|
+
> real paths are anyone with push/branch-create access (a wildcard `sub` then covers their
|
|
72
|
+
> branch), `pull_request_target` or `workflow_run` jobs that check out untrusted code, and a
|
|
73
|
+
> compromised third-party action running inside an already-trusted job.
|
|
74
|
+
|
|
75
|
+
It's the small, focused sibling of **[subvectors](https://github.com/Dashtid/subvectors)** — the
|
|
76
|
+
conformance test-vector suite that grades whether a cloud trust *condition* is well-formed,
|
|
77
|
+
matches, and is safe. This one works the other end: it inspects a single *token* against a policy
|
|
78
|
+
you write — nothing to configure, no cloud account, no network.
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install subcheck # v0.2.0 — first PyPI release rolling out
|
|
84
|
+
# or from a clone:
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or skip installing entirely and use the GitHub Action — see [In a workflow](#in-a-workflow).
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
Decode a token (claims only):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
subcheck --token "$TOKEN" # or: --token - (read the JWT from stdin)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Validate against a policy and gate on the result:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
subcheck --token "$TOKEN" --policy .github/oidc-policy.yaml
|
|
102
|
+
echo $? # 0 = all matched, 1 = a claim didn't match, 2 = usage/parse error
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Inputs (choose one): `--token <jwt>` (`-` for stdin), `--token-file <path>`, or
|
|
106
|
+
`--claims <decoded-claims.json>`. Output: `--format text` (default) or `json`.
|
|
107
|
+
|
|
108
|
+
> Prefer `--token -` (stdin) or `--token-file` over passing the JWT as an argument — a token on the
|
|
109
|
+
> command line leaks into the process list and shell history.
|
|
110
|
+
|
|
111
|
+
> **Scope (and honesty):** this **decodes** the token payload for inspection — it does **not
|
|
112
|
+
> verify the signature**. Verifying the signature and issuer against GitHub's JWKS is the cloud
|
|
113
|
+
> provider's job at role-assumption time. Use this to catch misconfigured *expectations* early,
|
|
114
|
+
> not as an authentication control.
|
|
115
|
+
|
|
116
|
+
## Policy
|
|
117
|
+
|
|
118
|
+
YAML or JSON. `issuer`/`audience` are shortcuts for the `iss`/`aud` claims; everything else lives
|
|
119
|
+
under `claims`. Each claim takes one or more of `equals`, `in` (list), `matches` (regex), `glob`,
|
|
120
|
+
and `required` (default `true`).
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
issuer: https://token.actions.githubusercontent.com
|
|
124
|
+
audience: sts.amazonaws.com
|
|
125
|
+
claims:
|
|
126
|
+
repository:
|
|
127
|
+
equals: acme/payments-api
|
|
128
|
+
sub:
|
|
129
|
+
matches: '^repo:acme/payments-api:(ref:refs/heads/main|environment:production)$'
|
|
130
|
+
runner_environment:
|
|
131
|
+
equals: github-hosted # reject self-hosted runners (see note below)
|
|
132
|
+
environment:
|
|
133
|
+
equals: production
|
|
134
|
+
required: true
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Shorthand: a bare string means `equals`, a list means `in`:
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
claims:
|
|
141
|
+
repository_owner: acme
|
|
142
|
+
ref: [refs/heads/main, refs/heads/release]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Matching notes:** `matches` uses `re.search`, so it is *not* anchored — `matches: pull_request`
|
|
146
|
+
matches that substring anywhere in the value. Anchor with `^…$` when you mean the whole claim (the
|
|
147
|
+
examples do). `equals`/`in` compare against the value's real JSON type, so quote a number you expect
|
|
148
|
+
as a string; `matches`/`glob` always operate on the stringified value.
|
|
149
|
+
|
|
150
|
+
`glob` is `fnmatch`-based: case-sensitive, `*` spans any characters (including `/` and `:`), `?`
|
|
151
|
+
matches one. That lines up with AWS IAM `StringLike` on the properties that matter, with **one
|
|
152
|
+
divergence** — `fnmatch` also honours POSIX character classes, so `glob: 'repo:acme/[ap]*'` matches
|
|
153
|
+
here while IAM treats `[` and `]` as literals and matches nothing. Avoid character classes if the
|
|
154
|
+
pattern is meant to mirror a trust policy. This is an expectation language, not a cloud-semantics
|
|
155
|
+
simulator (see [subvectors](https://github.com/Dashtid/subvectors) for that).
|
|
156
|
+
|
|
157
|
+
Claims that anchor the trust boundary (`iss`, `aud`, `sub`, `repository`, `repository_owner`,
|
|
158
|
+
`repository_id`, `repository_owner_id`, and `job_workflow_ref`) are reported at **high** severity;
|
|
159
|
+
contextual claims default to **medium**.
|
|
160
|
+
|
|
161
|
+
**Why check claims the cloud already checks?** Because for several of them it can't.
|
|
162
|
+
`runner_environment` is the clearest case: "reject self-hosted runners" is **not expressible in an
|
|
163
|
+
AWS IAM trust policy at all** — nor are `event_name`, `head_ref`, `base_ref`, or `workflow_ref`. AWS
|
|
164
|
+
exposes only a fixed set of GitHub claims as condition keys, and note that `repository_owner` is not
|
|
165
|
+
among them (only `repository_owner_id` is), so a name-based owner pin here has no trust-policy
|
|
166
|
+
equivalent. Asserting those claims in the job is the only place they can be enforced.
|
|
167
|
+
|
|
168
|
+
## Immutable subject claims (2026-07-15)
|
|
169
|
+
|
|
170
|
+
GitHub is migrating the `sub` claim to an **immutable format** that embeds numeric owner/repo IDs —
|
|
171
|
+
`repo:acme@123456/payments-api@456789:ref:refs/heads/main`. Adoption is automatic for repositories
|
|
172
|
+
created, renamed, or transferred after **2026-07-15**, but any repository can be switched on sooner
|
|
173
|
+
through the org-level or repo-level immutable-subject setting — so you cannot infer the format from
|
|
174
|
+
a repo's age. A trust condition (or a `sub` pattern here) written for the legacy
|
|
175
|
+
`repo:owner/repo:...` names silently stops matching, and deploys break with no code change.
|
|
176
|
+
|
|
177
|
+
Immutable subject claims are **github.com only**; GitHub Enterprise Server keeps the mutable
|
|
178
|
+
name-based format under its own `https://HOSTNAME/_services/token` issuer, and subcheck suppresses
|
|
179
|
+
these migration hints for any non-github.com issuer.
|
|
180
|
+
|
|
181
|
+
subcheck decodes both formats, reports which one a token uses, and flags the mismatch. Run a
|
|
182
|
+
post-migration token against a name-based policy and it points straight at the cause (rows trimmed):
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
$ subcheck --claims examples/claims-immutable.json --policy examples/policy.json
|
|
186
|
+
OIDC claim inspection: FAIL (6 pass, 1 fail, 0 missing)
|
|
187
|
+
...
|
|
188
|
+
[-] sub high claim 'sub'='repo:acme@123456/payments-api@456789:ref:refs/heads/main' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
Notes:
|
|
192
|
+
[i] sub uses the immutable format (repository_owner_id=123456, repository_id=456789); pin these numeric IDs in the cloud trust policy rather than mutable owner/repo names.
|
|
193
|
+
[i] the sub check failed while the token is immutable-format and the expected pattern looks name-based; update the expected sub, or pin repository_id / repository_owner_id instead.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The durable fix is to pin the numeric IDs — stable across renames and transfers — as in
|
|
197
|
+
[`examples/policy-immutable.json`](examples/policy-immutable.json):
|
|
198
|
+
|
|
199
|
+
```yaml
|
|
200
|
+
claims:
|
|
201
|
+
repository_owner_id: "123456"
|
|
202
|
+
repository_id: "456789"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`repository_id` and `repository_owner_id` are **not new** — they have been separate claims in every
|
|
206
|
+
token since January 2023 and are present on legacy-format tokens too. You do not have to wait for
|
|
207
|
+
the migration to pin them; doing it now is what makes a policy survive the switch.
|
|
208
|
+
|
|
209
|
+
## In a workflow
|
|
210
|
+
|
|
211
|
+
The one-line form — this repo ships an [`action.yml`](action.yml) that requests the job's OIDC
|
|
212
|
+
token and checks it against your policy:
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
permissions:
|
|
216
|
+
id-token: write
|
|
217
|
+
contents: read
|
|
218
|
+
steps:
|
|
219
|
+
- uses: actions/checkout@v4
|
|
220
|
+
- name: Verify the OIDC token is scoped as expected
|
|
221
|
+
uses: Dashtid/subcheck@v0.2.0 # or @main
|
|
222
|
+
with:
|
|
223
|
+
policy: .github/oidc-policy.yaml
|
|
224
|
+
audience: sts.amazonaws.com # default
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Or by hand, if you'd rather see every moving part:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
permissions:
|
|
231
|
+
id-token: write
|
|
232
|
+
contents: read
|
|
233
|
+
steps:
|
|
234
|
+
- uses: actions/checkout@v4
|
|
235
|
+
- run: pip install subcheck
|
|
236
|
+
- name: Verify the OIDC token is scoped as expected
|
|
237
|
+
run: |
|
|
238
|
+
TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
|
|
239
|
+
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
|
|
240
|
+
echo "$TOKEN" | subcheck --token - --policy .github/oidc-policy.yaml
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pip install -e ".[dev]"
|
|
247
|
+
pytest -q # tests
|
|
248
|
+
ruff check . # lint
|
|
249
|
+
bandit -r src # security lint
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md); good first issues are labelled.
|
|
253
|
+
|
|
254
|
+
## Related tools
|
|
255
|
+
|
|
256
|
+
- **[subvectors](https://github.com/Dashtid/subvectors)** — the sibling project, working the other
|
|
257
|
+
end of the same trust boundary. subcheck checks the token a job *received* against your expected
|
|
258
|
+
claims; subvectors is the *cloud side* — a cited, versioned suite of conformance test vectors
|
|
259
|
+
answering "does subject S satisfy trust condition C, and is C safe?" across AWS IAM, Azure FIC,
|
|
260
|
+
and GCP WIF. subvectors grades the trust *rules*; subcheck asserts the *token*.
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
subcheck/__init__.py,sha256=pZFAujfMcSfmRAldlDtIxGQ5gTuzR-y2Vr6az53fBBE,540
|
|
2
|
+
subcheck/__main__.py,sha256=4wiZ8LHV9WhvMBpR6zirrTCbdJMENUK2tmCLoGMYHfE,84
|
|
3
|
+
subcheck/cli.py,sha256=NR5HmRy7F1-MbRv52uTxBxdfzDc3G3SEQH9cYadAQ1Y,2676
|
|
4
|
+
subcheck/decoder.py,sha256=hcPuSNNvesm2LUYibJ8YrmGo0P7uiXhPZPdihWHWTF0,3851
|
|
5
|
+
subcheck/policy.py,sha256=mvxZzIDzZKD-ckfm3I8_hU6BYGiW8B7tssRTGBwWExg,3881
|
|
6
|
+
subcheck/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
subcheck/report.py,sha256=fTnZf3wEFWUbEpLVWLpjKkqgeoXshdP_SwPZweAEPkY,4128
|
|
8
|
+
subcheck/validator.py,sha256=n1BWCCK1CKOJDLIOd11jz-W2OIPadHwxR3i_8NMQmEg,1987
|
|
9
|
+
subcheck-0.2.0.dist-info/METADATA,sha256=fYON2N918XnZ9KMDAPST0K2iYJi3BMUcXbH87JoyXPY,12104
|
|
10
|
+
subcheck-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
subcheck-0.2.0.dist-info/entry_points.txt,sha256=7Dg3ZOD1DWJyJkph4ZwfLIJH2ZFpw3Oh-XwLGo_GlmA,47
|
|
12
|
+
subcheck-0.2.0.dist-info/licenses/LICENSE,sha256=SM21vnphyagwWbi9zY_fb64WEfpmTDjpXo0e_15iK7I,1069
|
|
13
|
+
subcheck-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Dashti
|
|
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.
|