damlisk 0.0.1__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.
- damlisk/__init__.py +21 -0
- damlisk/__main__.py +7 -0
- damlisk/cli/__init__.py +0 -0
- damlisk/cli/main.py +130 -0
- damlisk/detectors/__init__.py +0 -0
- damlisk/detectors/abstract_detector.py +49 -0
- damlisk/detectors/all_detectors.py +74 -0
- damlisk/detectors/assets/__init__.py +0 -0
- damlisk/detectors/assets/arbitrary_party.py +72 -0
- damlisk/detectors/assets/stuck_asset.py +68 -0
- damlisk/detectors/authorization/__init__.py +0 -0
- damlisk/detectors/authorization/controller_arg.py +50 -0
- damlisk/detectors/authorization/delegated_leak.py +129 -0
- damlisk/detectors/authorization/hardcoded_party.py +105 -0
- damlisk/detectors/authorization/over_disclosure.py +49 -0
- damlisk/detectors/authorization/unprotected_archive.py +51 -0
- damlisk/detectors/contracts/__init__.py +0 -0
- damlisk/detectors/contracts/stale_fetch.py +168 -0
- damlisk/detectors/contracts/unsafe_primitive.py +63 -0
- damlisk/detectors/contracts/untrusted_interface.py +90 -0
- damlisk/detectors/hygiene/__init__.py +0 -0
- damlisk/detectors/hygiene/aborting_choice.py +101 -0
- damlisk/detectors/hygiene/boolean_cst.py +74 -0
- damlisk/detectors/hygiene/boolean_equal.py +47 -0
- damlisk/detectors/hygiene/debug_trace.py +39 -0
- damlisk/detectors/hygiene/discarded_update.py +57 -0
- damlisk/detectors/hygiene/rtlo.py +75 -0
- damlisk/detectors/hygiene/tautological_compare.py +67 -0
- damlisk/detectors/hygiene/unused_field.py +126 -0
- damlisk/detectors/loops/__init__.py +0 -0
- damlisk/detectors/loops/ledger_op_in_loop.py +116 -0
- damlisk/detectors/loops/unbounded_input_loop.py +58 -0
- damlisk/detectors/time/__init__.py +0 -0
- damlisk/detectors/time/time_dependence.py +44 -0
- damlisk/detectors/time/weak_randomness.py +57 -0
- damlisk/detectors/upgrade/__init__.py +0 -0
- damlisk/detectors/upgrade/sdk_version.py +34 -0
- damlisk/detectors/validation/__init__.py +0 -0
- damlisk/detectors/validation/amount_tolerance.py +61 -0
- damlisk/detectors/validation/divide_multiply.py +74 -0
- damlisk/detectors/validation/hash_concat.py +107 -0
- damlisk/detectors/validation/ignored_optional.py +79 -0
- damlisk/detectors/validation/missing_amount.py +54 -0
- damlisk/detectors/validation/missing_ensure.py +66 -0
- damlisk/detectors/validation/nonconsuming_create.py +53 -0
- damlisk/detectors/validation/partial_function.py +53 -0
- damlisk/detectors/validation/time_argument.py +71 -0
- damlisk/detectors/validation/time_equality.py +79 -0
- damlisk/detectors/validation/unchecked_fetch.py +54 -0
- damlisk/detectors/validation/unconsumed_input.py +70 -0
- damlisk/exceptions.py +6 -0
- damlisk/lf/__init__.py +0 -0
- damlisk/lf/builtins.py +418 -0
- damlisk/lf/dar.py +111 -0
- damlisk/lf/flow.py +525 -0
- damlisk/lf/index.py +319 -0
- damlisk/lf/lf2.py +149 -0
- damlisk/lf/tree.py +88 -0
- damlisk/output/__init__.py +0 -0
- damlisk/output/finding.py +67 -0
- damlisk/output/formats.py +15 -0
- damlisk/version.py +4 -0
- damlisk-0.0.1.data/data/damlisk_git_commithash.txt +1 -0
- damlisk-0.0.1.dist-info/METADATA +215 -0
- damlisk-0.0.1.dist-info/RECORD +69 -0
- damlisk-0.0.1.dist-info/WHEEL +5 -0
- damlisk-0.0.1.dist-info/entry_points.txt +2 -0
- damlisk-0.0.1.dist-info/licenses/LICENSE +201 -0
- damlisk-0.0.1.dist-info/top_level.txt +1 -0
damlisk/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
from importlib.metadata import version as _version
|
|
3
|
+
from pathlib import Path as _Path
|
|
4
|
+
|
|
5
|
+
_hash_file = _Path(__file__).parent.joinpath("damlisk_git_commithash.txt")
|
|
6
|
+
|
|
7
|
+
if _hash_file.exists():
|
|
8
|
+
with _hash_file.open() as fp:
|
|
9
|
+
__commit__ = fp.read()
|
|
10
|
+
else:
|
|
11
|
+
__commit__ = "unknown"
|
|
12
|
+
|
|
13
|
+
__version__: str
|
|
14
|
+
try:
|
|
15
|
+
__version__ = _version("damlisk")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
from damlisk.version import version
|
|
18
|
+
|
|
19
|
+
__version__ = version
|
|
20
|
+
|
|
21
|
+
__long_version__ = f"{__version__}+commit.{__commit__}"
|
damlisk/__main__.py
ADDED
damlisk/cli/__init__.py
ADDED
|
File without changes
|
damlisk/cli/main.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from damlisk import __version__
|
|
8
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
9
|
+
from damlisk.detectors.all_detectors import DETECTORS
|
|
10
|
+
from damlisk.exceptions import DamliskException
|
|
11
|
+
from damlisk.lf.dar import Dar
|
|
12
|
+
from damlisk.lf.index import PackageIndex
|
|
13
|
+
from damlisk.output.finding import Finding, Impact
|
|
14
|
+
from damlisk.output.formats import render_json, render_text
|
|
15
|
+
|
|
16
|
+
_TRACEBACK = click.option(
|
|
17
|
+
"--traceback", is_flag=True, help="Show a Python traceback on error instead of a short message."
|
|
18
|
+
)
|
|
19
|
+
_DAR_ARG = click.argument("dar_path", type=click.Path(exists=True, path_type=Path))
|
|
20
|
+
_LEVELS = [i.name.lower() for i in Impact]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _split(value: str | None) -> set[str]:
|
|
24
|
+
return {item.strip() for item in (value or "").split(",") if item.strip()}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def select_detectors(detect: str | None, exclude: str | None) -> list[type[AbstractDetector]]:
|
|
28
|
+
wanted, skipped = _split(detect), _split(exclude)
|
|
29
|
+
unknown = (wanted | skipped) - {d.ARGUMENT for d in DETECTORS}
|
|
30
|
+
if unknown:
|
|
31
|
+
raise click.BadParameter(f"unknown detector(s): {', '.join(sorted(unknown))}")
|
|
32
|
+
return [d for d in DETECTORS if d.ARGUMENT in (wanted or {d.ARGUMENT}) - skipped]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(dar_path: Path, traceback: bool) -> Dar:
|
|
36
|
+
try:
|
|
37
|
+
return Dar.load(dar_path)
|
|
38
|
+
except DamliskException as err:
|
|
39
|
+
if traceback:
|
|
40
|
+
raise
|
|
41
|
+
logger.error(str(err))
|
|
42
|
+
sys.exit(2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fails(findings: list[Finding], fail_on: str) -> bool:
|
|
46
|
+
return fail_on != "none" and any(
|
|
47
|
+
f.impact.rank <= Impact[fail_on.upper()].rank for f in findings
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group(invoke_without_command=True)
|
|
52
|
+
@click.version_option(__version__, "--version", "-V", prog_name="damlisk")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def cli(ctx: click.Context) -> None:
|
|
55
|
+
"""Damlisk: static analyzer for Daml-LF packages (.dar)."""
|
|
56
|
+
if ctx.invoked_subcommand is None:
|
|
57
|
+
click.echo(ctx.get_help())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command("scan")
|
|
61
|
+
@_DAR_ARG
|
|
62
|
+
@click.option("--detect", default=None, help="Comma-separated detectors to run (default: all).")
|
|
63
|
+
@click.option("--exclude", default=None, help="Comma-separated detectors to skip.")
|
|
64
|
+
@click.option("--json", "as_json", is_flag=True, help="Output findings as JSON.")
|
|
65
|
+
@click.option(
|
|
66
|
+
"--pretty-json", "pretty", is_flag=True, help="Output JSON in pretty (indented) format."
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"--fail-on",
|
|
70
|
+
type=click.Choice([*_LEVELS, "none"]),
|
|
71
|
+
default="none",
|
|
72
|
+
show_default=True,
|
|
73
|
+
help="Exit with code 1 if a finding of this impact or higher is reported.",
|
|
74
|
+
)
|
|
75
|
+
@_TRACEBACK
|
|
76
|
+
def scan_cmd(
|
|
77
|
+
dar_path: Path,
|
|
78
|
+
detect: str | None,
|
|
79
|
+
exclude: str | None,
|
|
80
|
+
as_json: bool,
|
|
81
|
+
pretty: bool,
|
|
82
|
+
fail_on: str,
|
|
83
|
+
traceback: bool,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Run detectors over the main package of DAR_PATH."""
|
|
86
|
+
detectors = select_detectors(detect, exclude)
|
|
87
|
+
index = PackageIndex(_load(dar_path, traceback))
|
|
88
|
+
findings: list[Finding] = []
|
|
89
|
+
for det in detectors:
|
|
90
|
+
found = det(index).detect()
|
|
91
|
+
findings.extend(found)
|
|
92
|
+
if as_json or pretty:
|
|
93
|
+
click.echo(render_json(findings, pretty))
|
|
94
|
+
else:
|
|
95
|
+
click.echo(render_text(findings))
|
|
96
|
+
if _fails(findings, fail_on):
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cli.command("info")
|
|
101
|
+
@_DAR_ARG
|
|
102
|
+
@_TRACEBACK
|
|
103
|
+
def info_cmd(dar_path: Path, traceback: bool) -> None:
|
|
104
|
+
"""Show packages, Daml-LF versions and declarations of DAR_PATH."""
|
|
105
|
+
dar = _load(dar_path, traceback)
|
|
106
|
+
main, mods = dar.main, dar.main.modules
|
|
107
|
+
rows = (
|
|
108
|
+
("main", f"{main.name} {main.version} (LF {main.lf_ver}) {main.pkg_id}"),
|
|
109
|
+
("sdk", dar.sdk or "unknown"),
|
|
110
|
+
("modules", len(mods)),
|
|
111
|
+
("templates", sum(len(m.templates) for m in mods)),
|
|
112
|
+
("choices", sum(len(t.choices) for m in mods for t in m.templates)),
|
|
113
|
+
("interfaces", sum(len(m.interfaces) for m in mods)),
|
|
114
|
+
("interface instances", len(main.extras.implements)),
|
|
115
|
+
("exceptions", len(main.extras.exceptions)),
|
|
116
|
+
("values", sum(len(m.values) for m in mods)),
|
|
117
|
+
("packages", len(dar.packages)),
|
|
118
|
+
)
|
|
119
|
+
for label, value in rows:
|
|
120
|
+
click.echo(f"{label}: {value}")
|
|
121
|
+
packages = sorted(dar.packages, key=lambda p: (p.name, p.version))
|
|
122
|
+
for pkg in packages:
|
|
123
|
+
click.echo(f" {pkg.name} {pkg.version} (LF {pkg.lf_ver})")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@cli.command("list-detectors")
|
|
127
|
+
def list_detectors_cmd() -> None:
|
|
128
|
+
"""List available detectors."""
|
|
129
|
+
for d in DETECTORS:
|
|
130
|
+
click.echo(f"{d.ARGUMENT:<24} {d.IMPACT.value:<14} {d.CONFIDENCE.value:<8} {d.HELP}")
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from dazl.damlast import daml_lf_1 as lf
|
|
5
|
+
|
|
6
|
+
from damlisk.lf.flow import FieldFlow
|
|
7
|
+
from damlisk.lf.index import PackageIndex
|
|
8
|
+
from damlisk.output.finding import Confidence, Finding, Impact
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AbstractDetector(ABC):
|
|
12
|
+
ARGUMENT: ClassVar[str] = ""
|
|
13
|
+
HELP: ClassVar[str] = ""
|
|
14
|
+
IMPACT: ClassVar[Impact] = Impact.INFORMATIONAL
|
|
15
|
+
CONFIDENCE: ClassVar[Confidence] = Confidence.HIGH
|
|
16
|
+
WIKI_TITLE: ClassVar[str] = ""
|
|
17
|
+
WIKI_DESCRIPTION: ClassVar[str] = ""
|
|
18
|
+
WIKI_EXPLOIT_SCENARIO: ClassVar[str] = ""
|
|
19
|
+
WIKI_RECOMMENDATION: ClassVar[str] = ""
|
|
20
|
+
|
|
21
|
+
def __init__(self, index: PackageIndex) -> None:
|
|
22
|
+
self.index = index
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def _detect(self) -> list[Finding]: ...
|
|
26
|
+
|
|
27
|
+
def detect(self) -> list[Finding]:
|
|
28
|
+
return sorted(self._detect(), key=lambda f: (f.impact.rank, f.owner, f.line or 0))
|
|
29
|
+
|
|
30
|
+
def signers(self, flow: FieldFlow) -> dict[str, set[str]]:
|
|
31
|
+
"""Return the signatory fields of every main-package template, by template owner."""
|
|
32
|
+
found = {}
|
|
33
|
+
tmpls = self.index.template_decls()
|
|
34
|
+
for tmpl in tmpls:
|
|
35
|
+
sigs = tmpl.template.signatories
|
|
36
|
+
if sigs is not None:
|
|
37
|
+
found[tmpl.owner] = flow.fields(sigs, tmpl.template.param)
|
|
38
|
+
return found
|
|
39
|
+
|
|
40
|
+
def finding(self, owner: str, message: str, loc: lf.Location | None) -> Finding:
|
|
41
|
+
return Finding(
|
|
42
|
+
check=self.ARGUMENT,
|
|
43
|
+
impact=self.IMPACT,
|
|
44
|
+
confidence=self.CONFIDENCE,
|
|
45
|
+
message=message,
|
|
46
|
+
package=self.index.dar.main.name,
|
|
47
|
+
owner=owner,
|
|
48
|
+
loc=loc,
|
|
49
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
2
|
+
from damlisk.detectors.assets.arbitrary_party import ArbitraryParty
|
|
3
|
+
from damlisk.detectors.assets.stuck_asset import StuckAsset
|
|
4
|
+
from damlisk.detectors.authorization.controller_arg import ControllerFromArgument
|
|
5
|
+
from damlisk.detectors.authorization.delegated_leak import DelegatedLeak
|
|
6
|
+
from damlisk.detectors.authorization.hardcoded_party import HardcodedParty
|
|
7
|
+
from damlisk.detectors.authorization.over_disclosure import OverDisclosure
|
|
8
|
+
from damlisk.detectors.authorization.unprotected_archive import UnprotectedArchive
|
|
9
|
+
from damlisk.detectors.contracts.stale_fetch import StaleFetch
|
|
10
|
+
from damlisk.detectors.contracts.unsafe_primitive import UnsafePrimitive
|
|
11
|
+
from damlisk.detectors.contracts.untrusted_interface import UntrustedInterface
|
|
12
|
+
from damlisk.detectors.hygiene.aborting_choice import AbortingChoice
|
|
13
|
+
from damlisk.detectors.hygiene.boolean_cst import BooleanConstant
|
|
14
|
+
from damlisk.detectors.hygiene.boolean_equal import BooleanEqual
|
|
15
|
+
from damlisk.detectors.hygiene.debug_trace import DebugTrace
|
|
16
|
+
from damlisk.detectors.hygiene.discarded_update import DiscardedUpdate
|
|
17
|
+
from damlisk.detectors.hygiene.rtlo import Rtlo
|
|
18
|
+
from damlisk.detectors.hygiene.tautological_compare import TautologicalCompare
|
|
19
|
+
from damlisk.detectors.hygiene.unused_field import UnusedTemplateField
|
|
20
|
+
from damlisk.detectors.loops.ledger_op_in_loop import LedgerOpInLoop
|
|
21
|
+
from damlisk.detectors.loops.unbounded_input_loop import UnboundedInputLoop
|
|
22
|
+
from damlisk.detectors.time.time_dependence import TimeDependence
|
|
23
|
+
from damlisk.detectors.time.weak_randomness import WeakRandomness
|
|
24
|
+
from damlisk.detectors.upgrade.sdk_version import SdkVersion
|
|
25
|
+
from damlisk.detectors.validation.amount_tolerance import AmountTolerance
|
|
26
|
+
from damlisk.detectors.validation.divide_multiply import DivideBeforeMultiply
|
|
27
|
+
from damlisk.detectors.validation.hash_concat import HashConcatCollision
|
|
28
|
+
from damlisk.detectors.validation.ignored_optional import IgnoredOptional
|
|
29
|
+
from damlisk.detectors.validation.missing_amount import MissingAmountCheck
|
|
30
|
+
from damlisk.detectors.validation.missing_ensure import MissingEnsure
|
|
31
|
+
from damlisk.detectors.validation.nonconsuming_create import NonconsumingCreate
|
|
32
|
+
from damlisk.detectors.validation.partial_function import PartialFunction
|
|
33
|
+
from damlisk.detectors.validation.time_argument import TimeFromArgument
|
|
34
|
+
from damlisk.detectors.validation.time_equality import TimeEquality
|
|
35
|
+
from damlisk.detectors.validation.unchecked_fetch import UncheckedFetch
|
|
36
|
+
from damlisk.detectors.validation.unconsumed_input import UnconsumedInput
|
|
37
|
+
|
|
38
|
+
DETECTORS: tuple[type[AbstractDetector], ...] = (
|
|
39
|
+
BooleanConstant,
|
|
40
|
+
BooleanEqual,
|
|
41
|
+
ControllerFromArgument,
|
|
42
|
+
MissingAmountCheck,
|
|
43
|
+
UnusedTemplateField,
|
|
44
|
+
HardcodedParty,
|
|
45
|
+
PartialFunction,
|
|
46
|
+
DiscardedUpdate,
|
|
47
|
+
NonconsumingCreate,
|
|
48
|
+
UncheckedFetch,
|
|
49
|
+
TimeFromArgument,
|
|
50
|
+
AmountTolerance,
|
|
51
|
+
DivideBeforeMultiply,
|
|
52
|
+
UnconsumedInput,
|
|
53
|
+
AbortingChoice,
|
|
54
|
+
HashConcatCollision,
|
|
55
|
+
TimeEquality,
|
|
56
|
+
TautologicalCompare,
|
|
57
|
+
UnprotectedArchive,
|
|
58
|
+
MissingEnsure,
|
|
59
|
+
OverDisclosure,
|
|
60
|
+
UnsafePrimitive,
|
|
61
|
+
DebugTrace,
|
|
62
|
+
StuckAsset,
|
|
63
|
+
SdkVersion,
|
|
64
|
+
Rtlo,
|
|
65
|
+
DelegatedLeak,
|
|
66
|
+
UntrustedInterface,
|
|
67
|
+
ArbitraryParty,
|
|
68
|
+
TimeDependence,
|
|
69
|
+
WeakRandomness,
|
|
70
|
+
LedgerOpInLoop,
|
|
71
|
+
UnboundedInputLoop,
|
|
72
|
+
StaleFetch,
|
|
73
|
+
IgnoredOptional,
|
|
74
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
2
|
+
from damlisk.detectors.assets.stuck_asset import HOLDERS
|
|
3
|
+
from damlisk.lf.flow import THIS, WHOLE, Assign, FieldFlow
|
|
4
|
+
from damlisk.lf.index import ChoiceDecl, PackageIndex
|
|
5
|
+
from damlisk.output.finding import Confidence, Finding, Impact
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def granted(
|
|
9
|
+
index: PackageIndex, flow: FieldFlow, decl: ChoiceDecl, signers: set[str]
|
|
10
|
+
) -> list[Assign]:
|
|
11
|
+
"""Return the assets a choice builds whose owner comes from an unchecked argument.
|
|
12
|
+
|
|
13
|
+
A controller who holds or signs the current contract already has the authority to move
|
|
14
|
+
it, so such a choice is not reported. Neither is an owner taken from the controller, a
|
|
15
|
+
compared argument or a fetched contract, nor a choice whose argument controller is
|
|
16
|
+
compared. A controller passed into an exercise pays from their own assets, so that
|
|
17
|
+
choice is skipped too.
|
|
18
|
+
"""
|
|
19
|
+
choice = decl.choice
|
|
20
|
+
if decl.iface:
|
|
21
|
+
return []
|
|
22
|
+
ctrl = flow.fields(choice.controllers, decl.param)
|
|
23
|
+
if ctrl & (HOLDERS | signers):
|
|
24
|
+
return []
|
|
25
|
+
arg = choice.arg_binder.var
|
|
26
|
+
arg_ctrl = flow.fields(choice.controllers, arg)
|
|
27
|
+
usage = flow.fetch_usage(choice.update, arg, decl.param)
|
|
28
|
+
if arg_ctrl & (usage.compared | usage.called):
|
|
29
|
+
return []
|
|
30
|
+
if ctrl & flow.called(choice.update, decl.param):
|
|
31
|
+
return []
|
|
32
|
+
skipped = WHOLE | {THIS} | usage.compared | usage.fetched | arg_ctrl
|
|
33
|
+
found = []
|
|
34
|
+
for item in usage.assigned:
|
|
35
|
+
asset = item.template in index.templates and item.field in HOLDERS
|
|
36
|
+
if asset and item.source not in skipped:
|
|
37
|
+
found.append(item)
|
|
38
|
+
return sorted(found, key=lambda item: (item.source, item.field))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ArbitraryParty(AbstractDetector):
|
|
42
|
+
ARGUMENT = "value-to-arbitrary-party"
|
|
43
|
+
HELP = "Asset created for an owner from the choice argument by a non-owner controller"
|
|
44
|
+
IMPACT = Impact.HIGH
|
|
45
|
+
CONFIDENCE = Confidence.MEDIUM
|
|
46
|
+
WIKI_TITLE = "Value to arbitrary party"
|
|
47
|
+
WIKI_DESCRIPTION = (
|
|
48
|
+
"A choice creates an asset, a template with an `owner` or `holder` field, and sets that "
|
|
49
|
+
"field from the choice argument. Its controller neither holds nor signs the current "
|
|
50
|
+
"contract, so a party with no claim on the asset decides who receives it."
|
|
51
|
+
)
|
|
52
|
+
WIKI_EXPLOIT_SCENARIO = (
|
|
53
|
+
"`choice Transfer with requester; newOwner controller requester do create this with "
|
|
54
|
+
"owner = newOwner`: anyone who can see the asset moves it to any party."
|
|
55
|
+
)
|
|
56
|
+
WIKI_RECOMMENDATION = (
|
|
57
|
+
"Make the current owner or a signatory the controller, or check the new owner against "
|
|
58
|
+
"parties stored on the contract."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _detect(self) -> list[Finding]:
|
|
62
|
+
"""Report each choice that sends an asset to an owner taken from its argument."""
|
|
63
|
+
flow = FieldFlow(self.index.values)
|
|
64
|
+
signers = self.signers(flow)
|
|
65
|
+
findings = []
|
|
66
|
+
decls = self.index.choices()
|
|
67
|
+
for decl in decls:
|
|
68
|
+
items = granted(self.index, flow, decl, signers.get(decl.parent, set()))
|
|
69
|
+
for item in items:
|
|
70
|
+
message = f"{decl.owner}: argument `{item.source}` sets `{item.field}` of the asset"
|
|
71
|
+
findings.append(self.finding(decl.owner, message, decl.loc))
|
|
72
|
+
return findings
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
2
|
+
from damlisk.lf.flow import FieldFlow
|
|
3
|
+
from damlisk.lf.index import ChoiceDecl, TemplateDecl
|
|
4
|
+
from damlisk.output.finding import Confidence, Finding, Impact
|
|
5
|
+
|
|
6
|
+
HOLDERS = frozenset({"owner", "holder"})
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def holder_fields(decl: TemplateDecl) -> set[str]:
|
|
10
|
+
"""Return the `owner` and `holder` fields of a template."""
|
|
11
|
+
names = {field.field for field in decl.fields}
|
|
12
|
+
return names & HOLDERS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stuck(flow: FieldFlow, decl: TemplateDecl, choices: list[ChoiceDecl]) -> list[str]:
|
|
16
|
+
"""Return the holder fields of a template when none controls a consuming choice.
|
|
17
|
+
|
|
18
|
+
`Archive` does not count: it is controlled by all signatories, not by the holder.
|
|
19
|
+
"""
|
|
20
|
+
holders = holder_fields(decl)
|
|
21
|
+
if not holders:
|
|
22
|
+
return []
|
|
23
|
+
for choice in choices:
|
|
24
|
+
ch = choice.choice
|
|
25
|
+
if ch.consuming and ch.name != "Archive":
|
|
26
|
+
ctrl = flow.fields(ch.controllers, choice.param)
|
|
27
|
+
if ctrl & holders:
|
|
28
|
+
return []
|
|
29
|
+
return sorted(holders)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StuckAsset(AbstractDetector):
|
|
33
|
+
ARGUMENT = "stuck-asset"
|
|
34
|
+
HELP = "Asset its owner cannot move: no owner-controlled consuming choice"
|
|
35
|
+
IMPACT = Impact.MEDIUM
|
|
36
|
+
CONFIDENCE = Confidence.MEDIUM
|
|
37
|
+
WIKI_TITLE = "Stuck asset"
|
|
38
|
+
WIKI_DESCRIPTION = (
|
|
39
|
+
"A template with an `owner` or `holder` field has no consuming choice, other than "
|
|
40
|
+
"`Archive`, that this party controls. The holder cannot transfer, split or redeem the "
|
|
41
|
+
"asset alone; only other parties can move it."
|
|
42
|
+
)
|
|
43
|
+
WIKI_EXPLOIT_SCENARIO = (
|
|
44
|
+
"`template Holding with issuer; owner; amount` whose only choice is `Freeze` controlled "
|
|
45
|
+
"by the issuer: the owner holds the amount but can never transfer it, and the issuer "
|
|
46
|
+
"decides when, or whether, it moves."
|
|
47
|
+
)
|
|
48
|
+
WIKI_RECOMMENDATION = (
|
|
49
|
+
"Give the holder a consuming choice (`Transfer`, `Redeem`, `Split`) or document the "
|
|
50
|
+
"contract, often a rules contract, through which the holder moves the asset."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _detect(self) -> list[Finding]:
|
|
54
|
+
"""Report each template whose holder controls no consuming choice except `Archive`."""
|
|
55
|
+
flow = FieldFlow(self.index.values)
|
|
56
|
+
by_parent: dict[str, list[ChoiceDecl]] = {}
|
|
57
|
+
decls = self.index.choices()
|
|
58
|
+
for decl in decls:
|
|
59
|
+
by_parent.setdefault(decl.parent, []).append(decl)
|
|
60
|
+
findings = []
|
|
61
|
+
tmpls = self.index.template_decls()
|
|
62
|
+
for tmpl in tmpls:
|
|
63
|
+
fields = stuck(flow, tmpl, by_parent.get(tmpl.owner, []))
|
|
64
|
+
if fields:
|
|
65
|
+
names = ", ".join(f"`{name}`" for name in fields)
|
|
66
|
+
message = f"{tmpl.owner}: {names} controls no consuming choice except Archive"
|
|
67
|
+
findings.append(self.finding(tmpl.owner, message, tmpl.loc))
|
|
68
|
+
return findings
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
2
|
+
from damlisk.lf.flow import FieldFlow
|
|
3
|
+
from damlisk.lf.index import ChoiceDecl
|
|
4
|
+
from damlisk.output.finding import Confidence, Finding, Impact
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def unchecked_fields(flow: FieldFlow, decl: ChoiceDecl) -> list[str]:
|
|
8
|
+
choice = decl.choice
|
|
9
|
+
if decl.iface or not choice.consuming:
|
|
10
|
+
return []
|
|
11
|
+
party_ctrl = flow.fields(choice.controllers, decl.param)
|
|
12
|
+
if party_ctrl:
|
|
13
|
+
return []
|
|
14
|
+
arg = choice.arg_binder.var
|
|
15
|
+
arg_ctrl = flow.fields(choice.controllers, arg)
|
|
16
|
+
checked = flow.compared(choice.update, arg)
|
|
17
|
+
return sorted(arg_ctrl - checked)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ControllerFromArgument(AbstractDetector):
|
|
21
|
+
ARGUMENT = "controller-from-argument"
|
|
22
|
+
HELP = "Consuming choice controlled only by an unchecked choice argument"
|
|
23
|
+
IMPACT = Impact.HIGH
|
|
24
|
+
CONFIDENCE = Confidence.MEDIUM
|
|
25
|
+
WIKI_TITLE = "Controller taken from the choice argument"
|
|
26
|
+
WIKI_DESCRIPTION = (
|
|
27
|
+
"A consuming template choice whose controllers come only from choice argument fields, "
|
|
28
|
+
"and whose body never compares those fields: any party that can see the contract can "
|
|
29
|
+
"exercise it and archive or move the contract."
|
|
30
|
+
)
|
|
31
|
+
WIKI_EXPLOIT_SCENARIO = (
|
|
32
|
+
"`choice Transfer with requester : Party; newOwner : Party controller requester` lets an "
|
|
33
|
+
"observer pass itself as `requester` and transfer the asset to itself."
|
|
34
|
+
)
|
|
35
|
+
WIKI_RECOMMENDATION = (
|
|
36
|
+
"Control the choice by a contract party (`controller owner`), add a contract party as a "
|
|
37
|
+
'co-controller, or check the argument (`assertMsg "owner only" (requester == owner)`).'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _detect(self) -> list[Finding]:
|
|
41
|
+
flow = FieldFlow(self.index.values)
|
|
42
|
+
findings = []
|
|
43
|
+
choices = self.index.choices()
|
|
44
|
+
for decl in choices:
|
|
45
|
+
fields = unchecked_fields(flow, decl)
|
|
46
|
+
if fields:
|
|
47
|
+
names = ", ".join(f"`{name}`" for name in fields)
|
|
48
|
+
message = f"{decl.owner}: controlled by unchecked argument {names}"
|
|
49
|
+
findings.append(self.finding(decl.owner, message, decl.loc))
|
|
50
|
+
return findings
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from dazl.damlast import daml_lf_1 as lf
|
|
2
|
+
|
|
3
|
+
from damlisk.detectors.abstract_detector import AbstractDetector
|
|
4
|
+
from damlisk.lf.builtins import is_int, is_numeric, is_party
|
|
5
|
+
from damlisk.lf.flow import WHOLE, FieldFlow
|
|
6
|
+
from damlisk.lf.index import ChoiceDecl, PackageIndex, TemplateDecl
|
|
7
|
+
from damlisk.output.finding import Confidence, Finding, Impact
|
|
8
|
+
|
|
9
|
+
WRAPPERS = (lf.PrimType.LIST, lf.PrimType.OPTIONAL)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def party_or_amount(typ: lf.Type) -> bool:
|
|
13
|
+
"""Return True for `Party`, `Numeric` and `Int`, also inside a list or `Optional`."""
|
|
14
|
+
if typ.prim is not None and typ.prim.prim in WRAPPERS and typ.prim.args:
|
|
15
|
+
typ = typ.prim.args[0]
|
|
16
|
+
return is_party(typ) or is_numeric(typ) or is_int(typ)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensured(flow: FieldFlow, tmpl: TemplateDecl) -> set[str]:
|
|
20
|
+
"""Return the fields of a template that its `ensure` clause reads."""
|
|
21
|
+
precond = tmpl.template.precond
|
|
22
|
+
if precond is None:
|
|
23
|
+
return set()
|
|
24
|
+
used = flow.fields(precond, tmpl.template.param)
|
|
25
|
+
return used - WHOLE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LeakCheck:
|
|
29
|
+
def __init__(self, index: PackageIndex) -> None:
|
|
30
|
+
"""Collect the signatory and `ensure` fields of every template in the main package."""
|
|
31
|
+
self.index = index
|
|
32
|
+
self.flow = FieldFlow(index.values)
|
|
33
|
+
self.signers: dict[str, set[str]] = {}
|
|
34
|
+
self.ensures: dict[str, set[str]] = {}
|
|
35
|
+
tmpls = index.template_decls()
|
|
36
|
+
for tmpl in tmpls:
|
|
37
|
+
self.ensures[tmpl.owner] = ensured(self.flow, tmpl)
|
|
38
|
+
sigs = tmpl.template.signatories
|
|
39
|
+
if sigs is not None:
|
|
40
|
+
self.signers[tmpl.owner] = self.flow.fields(sigs, tmpl.template.param)
|
|
41
|
+
|
|
42
|
+
def risky(self, decl: ChoiceDecl, created: set[str]) -> set[str]:
|
|
43
|
+
"""Return the party and amount argument fields not read by a created template's `ensure`."""
|
|
44
|
+
names = set()
|
|
45
|
+
fields = self.index.arg_fields(decl)
|
|
46
|
+
for field in fields:
|
|
47
|
+
if party_or_amount(field.type):
|
|
48
|
+
names.add(field.field)
|
|
49
|
+
for owner in created:
|
|
50
|
+
names -= self.ensures.get(owner, set())
|
|
51
|
+
return names
|
|
52
|
+
|
|
53
|
+
def cosigned(self, created: set[str], parties: set[str]) -> bool:
|
|
54
|
+
"""Return True if a controller field is also a signatory field of a created template."""
|
|
55
|
+
for owner in created:
|
|
56
|
+
if self.signers.get(owner, set()) & parties:
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def leaked(self, decl: ChoiceDecl) -> list[str]:
|
|
61
|
+
"""Return the parties and amounts a non-signatory controller puts into a signed action.
|
|
62
|
+
|
|
63
|
+
The action counts as signed when it also carries contract data other than the
|
|
64
|
+
controller's own fields: a signatory party or a contract id the signatories stored.
|
|
65
|
+
Creates the controller signs too, and exercises the controller is passed into, act
|
|
66
|
+
on the controller's own behalf and are skipped. Arguments the choice compares or
|
|
67
|
+
bounds, and arguments that name the controller, are not reported.
|
|
68
|
+
"""
|
|
69
|
+
choice = decl.choice
|
|
70
|
+
if decl.iface:
|
|
71
|
+
return []
|
|
72
|
+
flow = self.flow
|
|
73
|
+
update = choice.update
|
|
74
|
+
ctrl = flow.fields(choice.controllers, decl.param)
|
|
75
|
+
if ctrl & self.signers.get(decl.parent, set()):
|
|
76
|
+
return []
|
|
77
|
+
this_made = flow.created(update, decl.param)
|
|
78
|
+
this_call = flow.called(update, decl.param)
|
|
79
|
+
if not (this_made | this_call) - ctrl:
|
|
80
|
+
return []
|
|
81
|
+
arg = choice.arg_binder.var
|
|
82
|
+
arg_ctrl = flow.fields(choice.controllers, arg)
|
|
83
|
+
created = self.index.created_templates(update)
|
|
84
|
+
found: set[str] = set()
|
|
85
|
+
if not self.cosigned(created, ctrl | arg_ctrl):
|
|
86
|
+
found |= flow.created(update, arg)
|
|
87
|
+
arg_call = flow.called(update, arg)
|
|
88
|
+
if not (this_call & ctrl or arg_call & arg_ctrl):
|
|
89
|
+
found |= arg_call
|
|
90
|
+
checked = flow.compared(update, arg) | flow.ordered(update, arg)
|
|
91
|
+
risky = self.risky(decl, created)
|
|
92
|
+
return sorted((found & risky) - checked - arg_ctrl)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DelegatedLeak(AbstractDetector):
|
|
96
|
+
ARGUMENT = "delegated-authority-leak"
|
|
97
|
+
HELP = "Non-signatory controller passes parties or amounts into a create or exercise"
|
|
98
|
+
IMPACT = Impact.HIGH
|
|
99
|
+
CONFIDENCE = Confidence.MEDIUM
|
|
100
|
+
WIKI_TITLE = "Delegated authority leak"
|
|
101
|
+
WIKI_DESCRIPTION = (
|
|
102
|
+
"A choice runs with the authority of the contract signatories, but its controller is "
|
|
103
|
+
"not one of them. When the choice creates or exercises with parties or amounts taken "
|
|
104
|
+
"from the choice argument, the controller makes the signatories sign whatever "
|
|
105
|
+
"contract or exercise they choose."
|
|
106
|
+
)
|
|
107
|
+
WIKI_EXPLOIT_SCENARIO = (
|
|
108
|
+
"`signatory issuer; observer holder; nonconsuming choice Mint with value controller "
|
|
109
|
+
"holder do create Token with issuer; owner = holder; amount = value`: the holder mints "
|
|
110
|
+
"tokens of any amount, each signed by the issuer."
|
|
111
|
+
)
|
|
112
|
+
WIKI_RECOMMENDATION = (
|
|
113
|
+
"Take parties and amounts from contract fields the signatories agreed to, bound and "
|
|
114
|
+
"check every argument before it is used (`assertMsg`, `ensure`), or make a signatory "
|
|
115
|
+
"the controller."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _detect(self) -> list[Finding]:
|
|
119
|
+
"""Report each non-signatory choice that sends parties or amounts into a signed action."""
|
|
120
|
+
check = LeakCheck(self.index)
|
|
121
|
+
findings = []
|
|
122
|
+
decls = self.index.choices()
|
|
123
|
+
for decl in decls:
|
|
124
|
+
fields = check.leaked(decl)
|
|
125
|
+
if fields:
|
|
126
|
+
names = ", ".join(f"`{name}`" for name in fields)
|
|
127
|
+
message = f"{decl.owner}: argument {names} used with the signatories' authority"
|
|
128
|
+
findings.append(self.finding(decl.owner, message, decl.loc))
|
|
129
|
+
return findings
|