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.
Files changed (69) hide show
  1. damlisk/__init__.py +21 -0
  2. damlisk/__main__.py +7 -0
  3. damlisk/cli/__init__.py +0 -0
  4. damlisk/cli/main.py +130 -0
  5. damlisk/detectors/__init__.py +0 -0
  6. damlisk/detectors/abstract_detector.py +49 -0
  7. damlisk/detectors/all_detectors.py +74 -0
  8. damlisk/detectors/assets/__init__.py +0 -0
  9. damlisk/detectors/assets/arbitrary_party.py +72 -0
  10. damlisk/detectors/assets/stuck_asset.py +68 -0
  11. damlisk/detectors/authorization/__init__.py +0 -0
  12. damlisk/detectors/authorization/controller_arg.py +50 -0
  13. damlisk/detectors/authorization/delegated_leak.py +129 -0
  14. damlisk/detectors/authorization/hardcoded_party.py +105 -0
  15. damlisk/detectors/authorization/over_disclosure.py +49 -0
  16. damlisk/detectors/authorization/unprotected_archive.py +51 -0
  17. damlisk/detectors/contracts/__init__.py +0 -0
  18. damlisk/detectors/contracts/stale_fetch.py +168 -0
  19. damlisk/detectors/contracts/unsafe_primitive.py +63 -0
  20. damlisk/detectors/contracts/untrusted_interface.py +90 -0
  21. damlisk/detectors/hygiene/__init__.py +0 -0
  22. damlisk/detectors/hygiene/aborting_choice.py +101 -0
  23. damlisk/detectors/hygiene/boolean_cst.py +74 -0
  24. damlisk/detectors/hygiene/boolean_equal.py +47 -0
  25. damlisk/detectors/hygiene/debug_trace.py +39 -0
  26. damlisk/detectors/hygiene/discarded_update.py +57 -0
  27. damlisk/detectors/hygiene/rtlo.py +75 -0
  28. damlisk/detectors/hygiene/tautological_compare.py +67 -0
  29. damlisk/detectors/hygiene/unused_field.py +126 -0
  30. damlisk/detectors/loops/__init__.py +0 -0
  31. damlisk/detectors/loops/ledger_op_in_loop.py +116 -0
  32. damlisk/detectors/loops/unbounded_input_loop.py +58 -0
  33. damlisk/detectors/time/__init__.py +0 -0
  34. damlisk/detectors/time/time_dependence.py +44 -0
  35. damlisk/detectors/time/weak_randomness.py +57 -0
  36. damlisk/detectors/upgrade/__init__.py +0 -0
  37. damlisk/detectors/upgrade/sdk_version.py +34 -0
  38. damlisk/detectors/validation/__init__.py +0 -0
  39. damlisk/detectors/validation/amount_tolerance.py +61 -0
  40. damlisk/detectors/validation/divide_multiply.py +74 -0
  41. damlisk/detectors/validation/hash_concat.py +107 -0
  42. damlisk/detectors/validation/ignored_optional.py +79 -0
  43. damlisk/detectors/validation/missing_amount.py +54 -0
  44. damlisk/detectors/validation/missing_ensure.py +66 -0
  45. damlisk/detectors/validation/nonconsuming_create.py +53 -0
  46. damlisk/detectors/validation/partial_function.py +53 -0
  47. damlisk/detectors/validation/time_argument.py +71 -0
  48. damlisk/detectors/validation/time_equality.py +79 -0
  49. damlisk/detectors/validation/unchecked_fetch.py +54 -0
  50. damlisk/detectors/validation/unconsumed_input.py +70 -0
  51. damlisk/exceptions.py +6 -0
  52. damlisk/lf/__init__.py +0 -0
  53. damlisk/lf/builtins.py +418 -0
  54. damlisk/lf/dar.py +111 -0
  55. damlisk/lf/flow.py +525 -0
  56. damlisk/lf/index.py +319 -0
  57. damlisk/lf/lf2.py +149 -0
  58. damlisk/lf/tree.py +88 -0
  59. damlisk/output/__init__.py +0 -0
  60. damlisk/output/finding.py +67 -0
  61. damlisk/output/formats.py +15 -0
  62. damlisk/version.py +4 -0
  63. damlisk-0.0.1.data/data/damlisk_git_commithash.txt +1 -0
  64. damlisk-0.0.1.dist-info/METADATA +215 -0
  65. damlisk-0.0.1.dist-info/RECORD +69 -0
  66. damlisk-0.0.1.dist-info/WHEEL +5 -0
  67. damlisk-0.0.1.dist-info/entry_points.txt +2 -0
  68. damlisk-0.0.1.dist-info/licenses/LICENSE +201 -0
  69. 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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: UTF-8 -*-
3
+
4
+ from damlisk.cli.main import cli
5
+
6
+ if __name__ == "__main__":
7
+ cli()
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