-
Notifications
You must be signed in to change notification settings - Fork 0
fix(scanner): reject spoofed wardline decorators #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| from collections.abc import Sequence | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
| from typing import TYPE_CHECKING, Any, cast | ||
|
|
||
| from wardline.core.finding import Finding, Kind, Location, Severity | ||
| from wardline.core.qualname import module_dotted_name | ||
|
|
@@ -59,6 +59,15 @@ class ParseProjectOutput: | |
| files: list[ParsedFile] | ||
| parse_findings: list[Finding] | ||
| dirty_modules: frozenset[str] | ||
| provider_fingerprint: str | ||
|
|
||
|
|
||
| def _provider_fingerprint_for_project(provider: TaintSourceProvider, project_modules: frozenset[str]) -> str: | ||
| project_fingerprint = getattr(provider, "fingerprint_for_project", None) | ||
| if callable(project_fingerprint): | ||
| typed_project_fingerprint = cast(Any, project_fingerprint) | ||
| return str(typed_project_fingerprint(project_modules)) | ||
| return provider.fingerprint() | ||
|
|
||
|
|
||
| def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutput: | ||
|
|
@@ -68,6 +77,17 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu | |
| parse_findings: list[Finding] = [] | ||
| dirty_modules: set[str] = set() | ||
| root = stage_input.root.resolve() | ||
| project_modules = frozenset( | ||
| module | ||
| for path in stage_input.files | ||
| if ( | ||
| module := module_dotted_name( | ||
| path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() | ||
| ) | ||
|
Comment on lines
+84
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a project uses a non- Useful? React with 👍 / 👎. |
||
| ) | ||
| is not None | ||
| ) | ||
| provider_fingerprint = _provider_fingerprint_for_project(stage_input.provider, project_modules) | ||
|
|
||
| for path in stage_input.files: | ||
| relpath = path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() | ||
|
|
@@ -90,7 +110,6 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu | |
| source = path.read_text(encoding="utf-8") | ||
| source_bytes = source.encode("utf-8") | ||
|
|
||
| provider_fingerprint = stage_input.provider.fingerprint() | ||
| from wardline.scanner.taint.project_resolver import _RESOLVER_VERSION | ||
| from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, compute_cache_key | ||
|
|
||
|
|
@@ -116,7 +135,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu | |
| ) | ||
| seeds = seed_function_taints( | ||
| entities, | ||
| ctx=SeedContext(module=module, alias_map=alias_map), | ||
| ctx=SeedContext(module=module, alias_map=alias_map, project_modules=project_modules), | ||
| provider=stage_input.provider, | ||
| ) | ||
| for ent in entities: | ||
|
|
@@ -205,6 +224,7 @@ def run_parse_project_stage(stage_input: ParseProjectInput) -> ParseProjectOutpu | |
| files=parsed_files, | ||
| parse_findings=parse_findings, | ||
| dirty_modules=frozenset(dirty_modules), | ||
| provider_fingerprint=provider_fingerprint, | ||
| ) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ | |
| from wardline.scanner.taint.provider import SeedContext | ||
|
|
||
| _VOCAB_PREFIX = "wardline.decorators" | ||
| _WARDLINE_ROOT = "wardline" | ||
| _TAINTSTATE_FQN = "wardline.core.taints.TaintState" | ||
|
|
||
|
|
||
|
|
@@ -78,6 +79,26 @@ def _resolve_decorator_fqn(deco: ast.expr, alias_map: Mapping[str, str]) -> str | |
| return _resolve_dotted_fqn(func, alias_map) | ||
|
|
||
|
|
||
| def _project_shadows_wardline(project_modules: frozenset[str]) -> bool: | ||
| """Return whether the scan target defines a local ``wardline`` package/module. | ||
|
|
||
| Builtin Wardline decorator declarations must refer to the installed marker | ||
| package, not a module supplied by the scanned project. If the project itself | ||
| contains ``wardline`` or anything below it, Python import resolution can bind | ||
| ``wardline.decorators`` to attacker-controlled code, so builtin matching fails | ||
| closed for the scan. | ||
| """ | ||
| return any(module == _WARDLINE_ROOT or module.startswith(_WARDLINE_ROOT + ".") for module in project_modules) | ||
|
|
||
|
|
||
| def _is_builtin_decorator_fqn(fqn: str, canonical_name: str, module_prefix: str) -> bool: | ||
| """Return whether *fqn* is one of Wardline's exact builtin decorator exports.""" | ||
| return fqn in { | ||
| f"{module_prefix}.{canonical_name}", | ||
| f"{module_prefix}.trust.{canonical_name}", | ||
| } | ||
|
|
||
|
|
||
| def _level_token(value: ast.expr, alias_map: Mapping[str, str]) -> str | None: | ||
| """Extract a TaintState name token from a keyword-argument value node. | ||
|
|
||
|
|
@@ -179,7 +200,7 @@ def taint_for(self, entity: Entity, ctx: SeedContext) -> SeedResult: | |
| candidates: list[FunctionTaint] = [] | ||
| unprovable: list[str] = [] | ||
| for deco in entity.node.decorator_list: | ||
| ft, unprov = self._match(deco, ctx.alias_map) | ||
| ft, unprov = self._match(deco, ctx.alias_map, ctx.project_modules) | ||
| if ft is not None: | ||
| candidates.append(ft) | ||
| elif unprov is not None: | ||
|
|
@@ -213,7 +234,21 @@ def fingerprint(self) -> str: | |
| return f"decorator-vocab:{REGISTRY_VERSION}" | ||
| return f"decorator-vocab:{REGISTRY_VERSION}+grammar:{_grammar_digest(self._boundary_types)}" | ||
|
|
||
| def _match(self, deco: ast.expr, alias_map: Mapping[str, str]) -> tuple[FunctionTaint | None, str | None]: | ||
| def fingerprint_for_project(self, project_modules: frozenset[str]) -> str: | ||
| """Fingerprint declaration inputs that are external to a single module. | ||
|
|
||
| Builtin seeds depend on whether the scanned project shadows ``wardline``; | ||
| bind that fact into summary-cache keys so a warm cache cannot reuse trusted | ||
| summaries across shadowed and unshadowed scan roots. | ||
| """ | ||
| return f"{self.fingerprint()}:wardline-shadowed={int(_project_shadows_wardline(project_modules))}" | ||
|
|
||
| def _match( | ||
| self, | ||
| deco: ast.expr, | ||
| alias_map: Mapping[str, str], | ||
| project_modules: frozenset[str], | ||
| ) -> tuple[FunctionTaint | None, str | None]: | ||
| """Match one decorator against the loaded boundary types. Returns: | ||
|
|
||
| ``(seed, None)`` — a boundary type matched and its levels proved; | ||
|
|
@@ -225,15 +260,18 @@ def _match(self, deco: ast.expr, alias_map: Mapping[str, str]) -> tuple[Function | |
| fqn = _resolve_decorator_fqn(deco, alias_map) | ||
| if fqn is None: | ||
| return None, None | ||
| # A decorator matches a boundary type when its FQN is UNDER the type's module | ||
| # prefix and its final segment is the canonical name. This accepts BOTH the | ||
| # package re-export (``wardline.decorators.trusted``) and the submodule path | ||
| # (``wardline.decorators.trust.trusted``) — preserving the pre-Track-2 matcher | ||
| # exactly (it used the same prefix + last-segment rule), and generalizing it | ||
| # consistently for custom types. | ||
| # Builtin Wardline markers are security-sensitive defaults. Match only the | ||
| # exact public re-export or implementation-module export, and reject them | ||
| # when the scanned project itself defines ``wardline`` (which would shadow | ||
| # the real marker package under normal import resolution). Custom grammar | ||
| # markers keep the documented prefix + canonical-name matching behavior. | ||
| last = fqn.rsplit(".", 1)[-1] | ||
| wardline_shadowed = _project_shadows_wardline(project_modules) | ||
| for bt in self._boundary_types: | ||
| if last != bt.canonical_name or not fqn.startswith(bt.module_prefix + "."): | ||
| if bt.builtin: | ||
| if wardline_shadowed or not _is_builtin_decorator_fqn(fqn, bt.canonical_name, bt.module_prefix): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a scanned module that defines a local object named Useful? React with 👍 / 👎. |
||
| continue | ||
| elif last != bt.canonical_name or not fqn.startswith(bt.module_prefix + "."): | ||
| continue | ||
| levels: dict[str, TaintState] = {} | ||
| unreadable = False | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
project_modulesis derived only fromstage_input.files, a repo can hide a localwardline/decorators/__init__.pywith anexcludepattern (or otherwise omit it from discovery) whileapp.pyis still scanned. At runtimefrom wardline.decorators import trustedinapp.pyresolves to that local package, but the scanner never sees the shadow here and still anchors@trustedas Wardline's builtin marker, preserving the spoofing false-green this patch is intended to remove.Useful? React with 👍 / 👎.