diff --git a/src/openultrasast/ruleset/obligations/javascript.toml b/src/openultrasast/ruleset/obligations/javascript.toml index ae5df3a..2145340 100644 --- a/src/openultrasast/ruleset/obligations/javascript.toml +++ b/src/openultrasast/ruleset/obligations/javascript.toml @@ -12,7 +12,7 @@ sensitivity = "high" [[operation]] id = "orm-write" kind = "protected_write" -calls = ["update", "updateOne", "updateMany", "deleteOne", "deleteMany", "delete", "create", "insert", "upsert", "save", "remove", "findByIdAndUpdate", "findByIdAndDelete"] +calls = ["updateOne", "updateMany", "deleteOne", "deleteMany", "findByIdAndUpdate", "findByIdAndDelete", "upsert", "insertOne", "insertMany", "prisma.update", "prisma.delete", "prisma.create"] requires = ["identity_constraint", "ownership_check"] sensitivity = "high" @@ -33,7 +33,7 @@ sensitivity = "high" [[operation]] id = "security-setting" kind = "security_setting" -calls = ["cors", "cookie", "session", "helmet", "listen", "use", "setHeader", "createClient", "from"] +calls = ["cors", "cookie", "session", "helmet", "app.listen", "setHeader", "createClient"] resource_arg = 0 requires = ["non_permissive_value"] sensitivity = "medium" diff --git a/src/openultrasast/ruleset/obligations/python.toml b/src/openultrasast/ruleset/obligations/python.toml index b9f2233..84a54fc 100644 --- a/src/openultrasast/ruleset/obligations/python.toml +++ b/src/openultrasast/ruleset/obligations/python.toml @@ -12,7 +12,7 @@ sensitivity = "high" [[operation]] id = "orm-write" kind = "protected_write" -calls = ["add", "delete", "update", "save", "commit", "insert_one", "update_one", "delete_one", "bulk_update"] +calls = ["session.add", "session.delete", "session.merge", "query.update", "query.delete", "objects.update", "objects.create", "objects.filter", "insert_one", "update_one", "delete_one", "bulk_update"] requires = ["identity_constraint", "ownership_check"] sensitivity = "high" @@ -33,7 +33,7 @@ sensitivity = "high" [[operation]] id = "security-setting" kind = "security_setting" -calls = ["CORS", "cross_origin", "set_cookie", "Fernet", "run", "config.update", "TrustedHostMiddleware", "add_middleware", "set_debug"] +calls = ["CORS", "cross_origin", "set_cookie", "app.run", "config.update", "TrustedHostMiddleware", "add_middleware", "set_debug"] resource_arg = 0 requires = ["non_permissive_value"] sensitivity = "medium" @@ -42,7 +42,7 @@ sensitivity = "medium" id = "route-guard" kind = "path_guard" decorators = ["login_required", "jwt_required", "auth_required", "roles_required", "permission_required", "admin_required", "requires_auth", "authenticated", "token_required"] -calls = ["token_validator", "verify_jwt_in_request", "get_jwt_identity", "check_auth", "require_login", "authenticate", "abort", "login_required", "current_user_required"] +calls = ["token_validator", "verify_jwt_in_request", "get_jwt_identity", "check_auth", "require_login", "authenticate", "login_required", "current_user_required"] [[discharger]] id = "identity-from-context" @@ -59,7 +59,7 @@ calls = ["check_owner", "is_owner", "assert_owner", "ensure_owner", "owns", "can [[discharger]] id = "safe-setting" kind = "non_permissive_value" -calls = ["CORS", "cross_origin", "set_cookie", "run", "config.update", "add_middleware"] +calls = ["CORS", "cross_origin", "set_cookie", "app.run", "config.update", "add_middleware"] permissive_values = ["'*'", "\"*\"", "True", "0.0.0.0", "None", "False", "allow_all", "disable"] [[discharger]] diff --git a/src/openultrasast/ruleset/obligations/typescript.toml b/src/openultrasast/ruleset/obligations/typescript.toml index 880a3ba..8e3c118 100644 --- a/src/openultrasast/ruleset/obligations/typescript.toml +++ b/src/openultrasast/ruleset/obligations/typescript.toml @@ -12,7 +12,7 @@ sensitivity = "high" [[operation]] id = "orm-write" kind = "protected_write" -calls = ["update", "updateOne", "updateMany", "deleteOne", "deleteMany", "delete", "create", "insert", "upsert", "save", "remove", "findByIdAndUpdate", "findByIdAndDelete"] +calls = ["updateOne", "updateMany", "deleteOne", "deleteMany", "findByIdAndUpdate", "findByIdAndDelete", "upsert", "insertOne", "insertMany", "prisma.update", "prisma.delete", "prisma.create"] requires = ["identity_constraint", "ownership_check"] sensitivity = "high" @@ -33,7 +33,7 @@ sensitivity = "high" [[operation]] id = "security-setting" kind = "security_setting" -calls = ["cors", "cookie", "session", "helmet", "listen", "use", "setHeader", "createClient", "from"] +calls = ["cors", "cookie", "session", "helmet", "app.listen", "setHeader", "createClient"] resource_arg = 0 requires = ["non_permissive_value"] sensitivity = "medium" diff --git a/src/openultrasast/semantic/obligations/__init__.py b/src/openultrasast/semantic/obligations/__init__.py index bc2ea9a..c08480a 100644 --- a/src/openultrasast/semantic/obligations/__init__.py +++ b/src/openultrasast/semantic/obligations/__init__.py @@ -6,6 +6,7 @@ ownership check, a non-permissive value, validated input). Facts are closed data findings never claim a proof rung. """ +from .check import ObligationFinding, ObligationResult, check_obligations, findings_to_static from .facts import ( DISCHARGER_KINDS, OPERATION_KINDS, @@ -25,10 +26,14 @@ __all__ = [ "DischargerFact", "ObligationFacts", "ObligationFactsError", + "ObligationFinding", + "ObligationResult", "ObligationShape", "OperationFact", + "check_obligations", "derive_obligation", "explain_skip", + "findings_to_static", "load_obligation_facts", "obligation_mechanisms", ] diff --git a/src/openultrasast/semantic/obligations/operations.py b/src/openultrasast/semantic/obligations/operations.py index cd88ea7..58770cb 100644 --- a/src/openultrasast/semantic/obligations/operations.py +++ b/src/openultrasast/semantic/obligations/operations.py @@ -103,6 +103,11 @@ def find_discharges( return tuple(_dedupe(witnesses)) +def valid_witness(witness: Discharge) -> bool: + """An identity constraint witnesses a discharge only when its value comes from the authenticated context.""" + return witness.kind != "identity_constraint" or witness.provenance == "authenticated_context" + + def covers(witness: Discharge, operation: Operation) -> bool: """A witness discharges an operation when it sits in the same function, has a kind the operation requires, and, for an identity constraint, binds its value from the authenticated context. Ordering along the path is the Dominance protocol's @@ -111,7 +116,7 @@ def covers(witness: Discharge, operation: Operation) -> bool: return False if witness.kind not in operation.requires: return False - if witness.kind == "identity_constraint" and witness.provenance != "authenticated_context": + if not valid_witness(witness): return False return not (witness.kind == "non_permissive_value" and witness.line != operation.line) @@ -130,7 +135,7 @@ def _decorator_witnesses(path: str, function: FunctionIR, fact: DischargerFact, out: list[Discharge] = [] for decorator in decorators: name = _decorator_name(decorator) - if any(candidate == name or candidate in decorator for candidate in fact.decorators): + if any(candidate == name for candidate in fact.decorators): # the parsed decorator name only, never free text out.append( Discharge( path=path, @@ -245,4 +250,4 @@ def _dedupe(witnesses: Sequence[Discharge]) -> list[Discharge]: return out -__all__ = ["SCOPES", "Discharge", "Operation", "covers", "find_discharges", "find_operations"] +__all__ = ["SCOPES", "Discharge", "Operation", "covers", "find_discharges", "find_operations", "valid_witness"] diff --git a/src/openultrasast/semantic/obligations/siblings.py b/src/openultrasast/semantic/obligations/siblings.py new file mode 100644 index 0000000..879a74a --- /dev/null +++ b/src/openultrasast/semantic/obligations/siblings.py @@ -0,0 +1,125 @@ +"""Sibling handler sets and consistency anomalies (authorization-obligations, Req 3). + +Handlers that operate on the same resource under the same router or module are siblings. When at least the configured +minimum number of siblings discharge an obligation and one does not (Req 3.2), that one is the anomaly; its evidence is +the siblings that discharge. A set with fewer members than the minimum is counted as under-populated and never produces +a finding (Req 3.3). Nothing here touches other findings. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path + +from .facts import DISCHARGER_KINDS +from .operations import Discharge, Operation, valid_witness + + +@dataclass(frozen=True) +class SiblingSet: + key: tuple[str, str] # (module path without suffix, resource token) + handlers: tuple[str, ...] # "path::function" + discharging: dict[str, tuple[str, ...]] # discharger kind -> handlers that carry a valid witness of that kind + operation_kinds: tuple[str, ...] + access_levels: dict[str, str] = field(default_factory=dict) # handler -> entry-point access classification + + +@dataclass(frozen=True) +class Anomaly: + handler: str + missing: str # discharger kind the siblings practice and this handler lacks + operation_kind: str + siblings: tuple[str, ...] # handlers that discharge + key: tuple[str, str] + access_level: str = "unknown" + evidence: tuple[str, ...] = () + + +def sibling_sets(entries: Sequence[object], operations: Sequence[Operation], discharges: Sequence[Discharge]) -> tuple[SiblingSet, ...]: + """Group entry-point handlers by (module, resource) over the operations they perform; record who discharges what.""" + by_handler_ops: dict[str, list[Operation]] = {} + for operation in operations: + by_handler_ops.setdefault(f"{operation.path}::{operation.function}", []).append(operation) + access: dict[str, str] = {} + for entry in entries: + name = getattr(entry, "function_name", None) + path = getattr(entry, "path", None) + if name and path: + access[f"{path}::{name}"] = str(getattr(entry, "access_level", "unknown")) + valid: dict[str, set[str]] = {} + for witness in discharges: + if valid_witness(witness): + valid.setdefault(f"{witness.path}::{witness.function}", set()).add(witness.kind) + groups: dict[tuple[str, str], dict[str, list[Operation]]] = {} + for handler, ops in by_handler_ops.items(): + if handler not in access: + continue # only entry-point handlers are siblings; helpers are reached through them + module = _module_of(handler.split("::", 1)[0]) + for operation in ops: + if operation.resource is None: + continue + groups.setdefault((module, operation.resource), {}).setdefault(handler, []).append(operation) + sets: list[SiblingSet] = [] + for key, members in sorted(groups.items()): + handlers = tuple(sorted(members)) + discharging = { + kind: tuple(sorted(handler for handler in handlers if kind in valid.get(handler, set()))) for kind in DISCHARGER_KINDS + } + kinds = tuple(sorted({operation.kind for ops in members.values() for operation in ops})) + sets.append( + SiblingSet( + key=key, + handlers=handlers, + discharging={kind: who for kind, who in discharging.items() if who}, + operation_kinds=kinds, + access_levels={handler: access.get(handler, "unknown") for handler in handlers}, + ) + ) + return tuple(sets) + + +def consistency_anomalies(sets: Sequence[SiblingSet], *, min_siblings: int) -> tuple[tuple[Anomaly, ...], tuple[tuple[str, str], ...]]: + """Anomalies where at least ``min_siblings`` siblings discharge a kind another sibling lacks; sets with fewer members than + ``min_siblings`` are returned as under-populated instead. One threshold, read as Req 3.2 states it.""" + anomalies: list[Anomaly] = [] + under: list[tuple[str, str]] = [] + for group in sets: + if len(group.handlers) < min_siblings: + under.append(group.key) + continue + for kind, dischargers in sorted(group.discharging.items()): + if len(dischargers) < min_siblings: + continue # one sibling does not set a norm + for handler in group.handlers: + if handler in dischargers: + continue + access = group.access_levels.get(handler, "unknown") + evidence: list[str] = [f"{sibling} discharges {kind}" for sibling in dischargers] + if kind == "path_guard": + evidence.append( + f"route access: {access}; siblings: " + + ", ".join(sorted({group.access_levels.get(s, "unknown") for s in dischargers})) + ) + for operation_kind in group.operation_kinds: + anomalies.append( + Anomaly( + handler=handler, + missing=kind, + operation_kind=operation_kind, + siblings=dischargers, + key=group.key, + access_level=access, + evidence=tuple(evidence), + ) + ) + return tuple(anomalies), tuple(under) + + +def _module_of(path: str) -> str: + """The module path without its suffix: `api/v1/books.py` -> `api/v1/books`. Two same-named files in different + directories are different modules, so their handlers are never siblings.""" + return Path(path).with_suffix("").as_posix() + + +__all__ = ["Anomaly", "SiblingSet", "consistency_anomalies", "sibling_sets"] diff --git a/tests/test_obligation_facts.py b/tests/test_obligation_facts.py index 15219a2..c13b054 100644 --- a/tests/test_obligation_facts.py +++ b/tests/test_obligation_facts.py @@ -60,9 +60,6 @@ def test_loader_rejects_kinds_and_fields_outside_the_closed_sets(tmp_path: Path) (tmp_path / "python.toml").write_text(good + '\n[[discharger]]\nid = "g"\nkind = "maybe_guard"\ndecorators = ["login_required"]\n') with pytest.raises(ObligationFactsError, match="maybe_guard"): load_obligation_facts(tmp_path) - (tmp_path / "python.toml").write_text(good + '\n[[discharger]]\nid = "g"\nkind = "maybe_guard"\ndecorators = ["login_required"]\n') - with pytest.raises(ObligationFactsError, match="maybe_guard"): - load_obligation_facts(tmp_path) def test_flow_facts_loader_ignores_the_obligation_facts_directory() -> None: diff --git a/tests/test_obligation_operations.py b/tests/test_obligation_operations.py index 3680a54..f4c9d65 100644 --- a/tests/test_obligation_operations.py +++ b/tests/test_obligation_operations.py @@ -10,9 +10,12 @@ APP = ( "from flask import request\n\n\n" "@app.route('/books/