rung-cli 0.1.2__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 (64) hide show
  1. rung/__init__.py +3 -0
  2. rung/__main__.py +3 -0
  3. rung/agents/__init__.py +15 -0
  4. rung/agents/hooks.py +66 -0
  5. rung/agents/install.py +120 -0
  6. rung/analysis/__init__.py +1 -0
  7. rung/analysis/plan.py +165 -0
  8. rung/analysis/report.py +125 -0
  9. rung/analysis/survey.py +319 -0
  10. rung/checks/__init__.py +1 -0
  11. rung/checks/base.py +59 -0
  12. rung/checks/command.py +122 -0
  13. rung/checks/deps.py +230 -0
  14. rung/checks/findings.py +153 -0
  15. rung/checks/formal.py +112 -0
  16. rung/checks/presets.py +96 -0
  17. rung/checks/prove.py +125 -0
  18. rung/checks/registry.py +35 -0
  19. rung/checks/runner.py +161 -0
  20. rung/checks/scan.py +152 -0
  21. rung/checks/shortcuts.py +109 -0
  22. rung/cli/__init__.py +157 -0
  23. rung/cli/commands/__init__.py +13 -0
  24. rung/cli/commands/common.py +41 -0
  25. rung/cli/commands/draft.py +214 -0
  26. rung/cli/commands/manage.py +112 -0
  27. rung/cli/commands/run.py +114 -0
  28. rung/cli/commands/setup.py +174 -0
  29. rung/cli/output.py +54 -0
  30. rung/core/__init__.py +1 -0
  31. rung/core/config.py +278 -0
  32. rung/core/errors.py +9 -0
  33. rung/core/git.py +236 -0
  34. rung/core/laws.py +124 -0
  35. rung/core/ledger.py +225 -0
  36. rung/core/proc.py +58 -0
  37. rung/core/redact.py +36 -0
  38. rung/drafting/__init__.py +1 -0
  39. rung/drafting/common.py +86 -0
  40. rung/drafting/lean.py +117 -0
  41. rung/drafting/property.py +339 -0
  42. rung/drafting/providers.py +294 -0
  43. rung/drafting/runtime.py +186 -0
  44. rung/drafting/sandbox.py +63 -0
  45. rung/drafting/tla.py +97 -0
  46. rung/engines/__init__.py +1 -0
  47. rung/engines/drt.py +123 -0
  48. rung/engines/lean.py +209 -0
  49. rung/engines/mutate.py +138 -0
  50. rung/engines/sarif.py +223 -0
  51. rung/engines/shortcuts.py +426 -0
  52. rung/engines/tla.py +199 -0
  53. rung/guides/__init__.py +20 -0
  54. rung/guides/lean.md +64 -0
  55. rung/guides/property.md +48 -0
  56. rung/guides/rules.md +15 -0
  57. rung/guides/runtime.md +94 -0
  58. rung/guides/tla.md +58 -0
  59. rung/guides/verify.md +14 -0
  60. rung_cli-0.1.2.dist-info/METADATA +296 -0
  61. rung_cli-0.1.2.dist-info/RECORD +64 -0
  62. rung_cli-0.1.2.dist-info/WHEEL +4 -0
  63. rung_cli-0.1.2.dist-info/entry_points.txt +2 -0
  64. rung_cli-0.1.2.dist-info/licenses/LICENSE +202 -0
rung/checks/deps.py ADDED
@@ -0,0 +1,230 @@
1
+ """Deps checker: every newly added dependency must exist and must not be brand new.
2
+
3
+ Coding agents sometimes add packages that do not exist; attackers register those
4
+ names (slopsquatting) or publish look-alikes shortly before they are used. This
5
+ check reads the dependency manifests, finds packages that are new relative to
6
+ the merge base (or to a recorded baseline), and asks the public registry about
7
+ each one. A package that is missing, or younger than `min_age_days`, blocks
8
+ until a person adds it to `allow`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import tomllib
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from collections.abc import Callable
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import dataclass
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+
26
+ from rung.checks.base import Checker, Context, Outcome
27
+ from rung.core import git
28
+ from rung.core.config import Check
29
+ from rung.core.errors import RungError
30
+
31
+ MANIFESTS = ["**/requirements*.txt", "**/requirements/*.txt", "**/pyproject.toml", "**/package.json"]
32
+ _REQ_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Package:
37
+ ecosystem: str # pypi | npm
38
+ name: str
39
+
40
+
41
+ def _normalize_pypi(name: str) -> str:
42
+ return re.sub(r"[-_.]+", "-", name).lower()
43
+
44
+
45
+ def requirement_name(spec: str) -> str:
46
+ """The normalized package name in a requirement such as `playwright>=1.40`, or empty."""
47
+ m = _REQ_NAME.match(spec)
48
+ return _normalize_pypi(m.group(1)) if m else ""
49
+
50
+
51
+ def _pep508_names(specs: list[str]) -> set[str]:
52
+ names = set()
53
+ for spec in specs:
54
+ if m := _REQ_NAME.match(spec):
55
+ names.add(_normalize_pypi(m.group(1)))
56
+ return names
57
+
58
+
59
+ def parse_manifest(
60
+ path: str, text: str, read: Callable[[str], str | None] | None = None, seen: frozenset[str] = frozenset()
61
+ ) -> set[Package]:
62
+ """Direct dependencies declared in one manifest file.
63
+
64
+ `read(relative_path)` returns another file's text (or None), used to follow `-r` includes
65
+ in requirements files at the same revision. Includes outside the repository, and cycles, are
66
+ not followed. A manifest that cannot be parsed raises RungError.
67
+ """
68
+ try:
69
+ return _parse_manifest(path, text, read, seen | {path})
70
+ except (json.JSONDecodeError, tomllib.TOMLDecodeError, AttributeError, TypeError) as exc:
71
+ raise RungError(f"{path}: cannot read its dependencies ({exc})") from exc
72
+
73
+
74
+ def _parse_manifest(
75
+ path: str, text: str, read: Callable[[str], str | None] | None, seen: frozenset[str]
76
+ ) -> set[Package]:
77
+ name = Path(path).name
78
+ if name.endswith(".txt"):
79
+ names: set[str] = set()
80
+ specs = []
81
+ for raw in text.splitlines():
82
+ line = raw.split("#", 1)[0].strip()
83
+ if line.startswith(("-r ", "--requirement ", "-c ", "--constraint ")) and read:
84
+ included = os.path.normpath(Path(path).parent / line.split(None, 1)[1].strip())
85
+ if included.startswith(("..", "/")) or included in seen:
86
+ continue
87
+ if (inner := read(included)) is not None:
88
+ names |= {p.name for p in parse_manifest(included, inner, read, seen)}
89
+ elif line and not line.startswith(("-", "git+", "http:", "https:", ".", "/")):
90
+ specs.append(line)
91
+ return {Package("pypi", n) for n in names | _pep508_names(specs)}
92
+ if name == "pyproject.toml":
93
+ data = tomllib.loads(text)
94
+ project = data.get("project", {})
95
+ specs = list(project.get("dependencies", []))
96
+ for group in project.get("optional-dependencies", {}).values():
97
+ specs += group
98
+ for group in data.get("dependency-groups", {}).values():
99
+ specs += [s for s in group if isinstance(s, str)]
100
+ poetry = data.get("tool", {}).get("poetry", {})
101
+ poetry_tables = [poetry.get("dependencies", {}), poetry.get("dev-dependencies", {})]
102
+ poetry_tables += [g.get("dependencies", {}) for g in poetry.get("group", {}).values()]
103
+ for table in poetry_tables:
104
+ specs += [k for k, v in table.items() if k.lower() != "python" and not _poetry_local(v)]
105
+ own = _normalize_pypi(project.get("name", "")) if project.get("name") else None
106
+ return {Package("pypi", n) for n in _pep508_names(specs) if n != own}
107
+ if name == "package.json":
108
+ data = json.loads(text)
109
+ packages: set[str] = set()
110
+ for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
111
+ for dep, version in (data.get(key) or {}).items():
112
+ version = str(version)
113
+ if version.startswith("npm:"): # an alias: vet the package actually installed
114
+ target = version[4:]
115
+ packages.add(target.rsplit("@", 1)[0] if target.count("@") > (1 if target.startswith("@") else 0)
116
+ else target) # fmt: skip
117
+ elif not version.startswith(("file:", "link:", "workspace:", "git", "http", "github:")):
118
+ packages.add(dep)
119
+ return {Package("npm", n) for n in packages}
120
+ return set()
121
+
122
+
123
+ def _poetry_local(spec: object) -> bool:
124
+ return isinstance(spec, dict) and any(k in spec for k in ("path", "git", "url"))
125
+
126
+
127
+ def fetch_json(url: str, timeout: int = 20) -> dict | None:
128
+ """GET a registry document; None means the package does not exist."""
129
+ request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "rung"})
130
+ try:
131
+ with urllib.request.urlopen(request, timeout=timeout) as resp:
132
+ return json.loads(resp.read())
133
+ except urllib.error.HTTPError as exc:
134
+ if exc.code == 404:
135
+ return None
136
+ raise RungError(f"{url} returned HTTP {exc.code}") from exc
137
+ except urllib.error.URLError as exc:
138
+ raise RungError(f"cannot reach {url}: {exc.reason}") from exc
139
+ except (TimeoutError, OSError) as exc:
140
+ raise RungError(f"{url} did not answer: {exc}") from exc
141
+ except json.JSONDecodeError as exc:
142
+ raise RungError(f"{url} returned malformed JSON") from exc
143
+
144
+
145
+ def first_published(pkg: Package) -> datetime | None:
146
+ """When the package first appeared on its registry; None if it does not exist."""
147
+ if pkg.ecosystem == "pypi":
148
+ doc = fetch_json(f"https://pypi.org/pypi/{urllib.parse.quote(pkg.name)}/json")
149
+ if doc is None:
150
+ return None
151
+ times = [
152
+ f["upload_time_iso_8601"]
153
+ for files in (doc.get("releases") or {}).values()
154
+ for f in files
155
+ if f.get("upload_time_iso_8601")
156
+ ]
157
+ return min((datetime.fromisoformat(t.replace("Z", "+00:00")) for t in times), default=datetime.now(UTC))
158
+ doc = fetch_json(f"https://registry.npmjs.org/{urllib.parse.quote(pkg.name, safe='@')}")
159
+ if doc is None:
160
+ return None
161
+ created = (doc.get("time") or {}).get("created")
162
+ return datetime.fromisoformat(created.replace("Z", "+00:00")) if created else datetime.now(UTC)
163
+
164
+
165
+ def _is_manifest(path: str) -> bool:
166
+ return any(git.glob_match(p, path) for p in MANIFESTS) and "node_modules/" not in path
167
+
168
+
169
+ def declared(root: Path) -> set[Package]:
170
+ def read(rel: str) -> str | None:
171
+ target = root / rel
172
+ return target.read_text(errors="ignore") if target.is_file() else None
173
+
174
+ out: set[Package] = set()
175
+ for rel in (f for f in git.list_files(root) if _is_manifest(f)):
176
+ out |= parse_manifest(rel, read(rel) or "", read)
177
+ return out
178
+
179
+
180
+ def declared_at(root: Path, commit: str) -> set[Package]:
181
+ """Dependencies declared at `commit`, reading that revision's own manifests (renames included)."""
182
+
183
+ def read(rel: str) -> str | None:
184
+ try:
185
+ return git.show(root, commit, rel)
186
+ except git.GitError:
187
+ return None
188
+
189
+ out: set[Package] = set()
190
+ for rel in (f for f in git.files_at(root, commit) if _is_manifest(f)):
191
+ out |= parse_manifest(rel, read(rel) or "", read)
192
+ return out
193
+
194
+
195
+ class DepsChecker(Checker):
196
+ kind = "deps"
197
+ summary = "New dependencies must exist on their registry and not be brand new"
198
+
199
+ def max_age_days(self, check: Check) -> int | None:
200
+ # A package can be published, yanked or taken over without any file here changing.
201
+ return check.get_int("max_age_days", 1) or None
202
+
203
+ def default_covers(self, check: Check) -> list[str]:
204
+ return list(MANIFESTS)
205
+
206
+ def run(self, ctx: Context, check: Check) -> Outcome:
207
+ min_age = check.get_int("min_age_days", 30)
208
+ allow = {_normalize_pypi(a) for a in check.get_list("allow")}
209
+ head = declared(ctx.root)
210
+ base = declared_at(ctx.root, ctx.base) if ctx.base else set()
211
+ added = sorted(
212
+ (p for p in head - base if _normalize_pypi(p.name) not in allow), key=lambda p: (p.ecosystem, p.name)
213
+ )
214
+ if not added:
215
+ return Outcome("pass", f"no new dependencies to vet ({len(head)} declared)")
216
+
217
+ with ThreadPoolExecutor(max_workers=8) as pool:
218
+ published = dict(zip(added, pool.map(first_published, added), strict=True))
219
+ now = datetime.now(UTC)
220
+ missing = [p for p, t in published.items() if t is None]
221
+ young = [(p, (now - t).days) for p, t in published.items() if t is not None and (now - t).days < min_age]
222
+
223
+ scope = f"new since {ctx.base[:10]}" if ctx.base else "declared"
224
+ lines = [f"{len(added)} {scope} package(s) vetted against their registries"]
225
+ lines += [f"MISSING {p.ecosystem}:{p.name}: not on the registry (hallucinated or private?)" for p in missing]
226
+ lines += [f"NEW {p.ecosystem}:{p.name}: first published {d} day(s) ago" for p, d in young]
227
+ if missing or young:
228
+ lines.append("If a package is intended, add it to `allow` for this check after reviewing it.")
229
+ return Outcome("fail", f"{len(missing)} missing, {len(young)} younger than {min_age} days", lines)
230
+ return Outcome("pass", f"{len(added)} {scope} package(s) exist and are at least {min_age} days old", lines)
@@ -0,0 +1,153 @@
1
+ """Baselines and waivers: how a team adopts a scanner without drowning in old findings.
2
+
3
+ Turning on a security scanner in a real codebase usually produces hundreds of
4
+ existing findings, and a gate that fails on all of them gets switched off. Rung
5
+ gates on what a change *introduces* instead. The comparison point is either the
6
+ merge base of the branch (computed fresh) or a committed baseline file recorded
7
+ deliberately with `rung baseline`.
8
+
9
+ A finding that must stay can be waived, but only with a reason, an approver and
10
+ an expiry date. Expired waivers stop applying and are reported, so "temporary"
11
+ exceptions cannot quietly become permanent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import tomllib
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, date, datetime
20
+ from pathlib import Path
21
+
22
+ from rung.core.config import toml_value, write_atomic
23
+ from rung.engines.sarif import Finding, at_least, diff
24
+
25
+ BASELINE_DIR = Path(".rung") / "baselines"
26
+ WAIVERS_FILE = Path(".rung") / "waivers.toml"
27
+
28
+
29
+ def baseline_path_rel(check_id: str) -> str:
30
+ return (BASELINE_DIR / f"{check_id}.json").as_posix()
31
+
32
+
33
+ def baseline_path(root: Path, check_id: str) -> Path:
34
+ return root / baseline_path_rel(check_id)
35
+
36
+
37
+ def load_baseline(root: Path, check_id: str) -> list[Finding] | None:
38
+ path = baseline_path(root, check_id)
39
+ if not path.exists():
40
+ return None
41
+ data = json.loads(path.read_text())
42
+ return [Finding.from_dict(f) for f in data.get("findings", [])]
43
+
44
+
45
+ def save_baseline(root: Path, check_id: str, findings: list[Finding], commit: str) -> Path:
46
+ path = baseline_path(root, check_id)
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ payload = {
49
+ "check": check_id,
50
+ "recorded": datetime.now(UTC).isoformat(timespec="seconds"),
51
+ "commit": commit,
52
+ "findings": [f.to_dict() for f in sorted(findings, key=lambda f: (f.path, f.rule, f.fingerprint))],
53
+ }
54
+ write_atomic(path, json.dumps(payload, indent=2) + "\n")
55
+ return path
56
+
57
+
58
+ @dataclass
59
+ class Waiver:
60
+ check: str
61
+ fingerprint: str
62
+ reason: str
63
+ approved_by: str
64
+ expires: date
65
+
66
+ def active(self, today: date) -> bool:
67
+ return today <= self.expires
68
+
69
+
70
+ def load_waivers(root: Path) -> list[Waiver]:
71
+ path = root / WAIVERS_FILE
72
+ if not path.exists():
73
+ return []
74
+ raw = tomllib.loads(path.read_text())
75
+ out = []
76
+ for w in raw.get("waiver", []):
77
+ expires = w["expires"]
78
+ if isinstance(expires, str):
79
+ expires = date.fromisoformat(expires)
80
+ out.append(Waiver(w["check"], w["fingerprint"], w["reason"], w["approved_by"], expires))
81
+ return out
82
+
83
+
84
+ def add_waiver(root: Path, waiver: Waiver) -> Path:
85
+ if not waiver.reason.strip():
86
+ raise ValueError("a waiver needs a reason")
87
+ path = root / WAIVERS_FILE
88
+ existing = path.read_text() if path.exists() else ""
89
+ block = (
90
+ "\n[[waiver]]\n"
91
+ f"check = {toml_value(waiver.check)}\n"
92
+ f"fingerprint = {toml_value(waiver.fingerprint)}\n"
93
+ f"reason = {toml_value(waiver.reason)}\n"
94
+ f"approved_by = {toml_value(waiver.approved_by)}\n"
95
+ f"expires = {waiver.expires.isoformat()}\n"
96
+ )
97
+ text = existing + block
98
+ tomllib.loads(text) # never write a waivers file Rung could not read back
99
+ write_atomic(path, text)
100
+ return path
101
+
102
+
103
+ @dataclass
104
+ class Triage:
105
+ blocking: list[Finding] = field(default_factory=list)
106
+ nonblocking: list[Finding] = field(default_factory=list)
107
+ waived: list[Finding] = field(default_factory=list)
108
+ resolved: list[Finding] = field(default_factory=list)
109
+ expired: list[Waiver] = field(default_factory=list)
110
+ compared_with: str = "nothing (every finding counts as new)"
111
+ valid_until: str = "" # the earliest expiry among waivers this result relied on
112
+
113
+
114
+ def triage(
115
+ check_id: str,
116
+ head: list[Finding],
117
+ base: list[Finding] | None,
118
+ waivers: list[Waiver],
119
+ *,
120
+ fail_on: str,
121
+ today: date | None = None,
122
+ compared_with: str = "",
123
+ ) -> Triage:
124
+ """Split the head run's findings into what blocks, what does not, and what is waived."""
125
+ today = today or date.today()
126
+ mine = [w for w in waivers if w.check == check_id]
127
+ active = {w.fingerprint for w in mine if w.active(today)}
128
+ expired = [w for w in mine if not w.active(today)]
129
+ introduced, resolved = diff(base or [], head)
130
+ # A finding that already existed below the threshold and now meets it has escalated: it blocks too.
131
+ base_levels: dict[str, str] = {}
132
+ for f in base or []:
133
+ if f.fingerprint not in base_levels or at_least(f.level, base_levels[f.fingerprint]):
134
+ base_levels[f.fingerprint] = f.level
135
+ new_ids = {id(f) for f in introduced}
136
+ escalated = [
137
+ f for f in head
138
+ if id(f) not in new_ids and f.fingerprint in base_levels
139
+ and at_least(f.level, fail_on) and not at_least(base_levels[f.fingerprint], fail_on)
140
+ ] # fmt: skip
141
+ result = Triage(resolved=resolved, expired=expired)
142
+ if compared_with:
143
+ result.compared_with = compared_with
144
+ for f in [*introduced, *escalated]:
145
+ if f.fingerprint in active:
146
+ result.waived.append(f)
147
+ elif at_least(f.level, fail_on):
148
+ result.blocking.append(f)
149
+ else:
150
+ result.nonblocking.append(f)
151
+ used = [w.expires for w in mine if w.active(today) and any(f.fingerprint == w.fingerprint for f in result.waived)]
152
+ result.valid_until = min(used).isoformat() if used else ""
153
+ return result
rung/checks/formal.py ADDED
@@ -0,0 +1,112 @@
1
+ """Formal checkers: TLA+ model checking, Lean proofs, and differential testing against a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+
7
+ from rung.checks.base import Checker, Context, Outcome
8
+ from rung.core.config import Check, ConfigError
9
+ from rung.engines import drt, lean, tla
10
+
11
+
12
+ class TLAChecker(Checker):
13
+ kind = "tla"
14
+ summary = "Model-check a TLA+ spec with TLC"
15
+
16
+ def specs(self, check: Check) -> list[str]:
17
+ files = [check.require("spec")]
18
+ if cfg := check.get("config"):
19
+ files.append(cfg)
20
+ return files
21
+
22
+ def run(self, ctx: Context, check: Check) -> Outcome:
23
+ cfg = check.get("config")
24
+ if not cfg:
25
+ raise ConfigError(f"check {check.id!r}: a TLA+ check needs a `config` (.cfg) listing what to check")
26
+ declared = tla.declared_properties(ctx.root / cfg)
27
+ if not declared["invariants"] and not declared["properties"]:
28
+ return Outcome("fail", "the .cfg declares no INVARIANT or PROPERTY, so TLC checked nothing", [
29
+ f"add e.g. `INVARIANT TypeOK` to {cfg}",
30
+ ]) # fmt: skip
31
+ result = tla.run(
32
+ ctx.root / check.require("spec"),
33
+ ctx.root / cfg if cfg else None,
34
+ timeout=check.get_int("timeout", 600),
35
+ )
36
+ lines = [f"{result.distinct_states} distinct states explored"] if result.distinct_states else []
37
+ checked = declared["invariants"] + declared["properties"]
38
+ lines.append("checked: " + ", ".join(checked))
39
+ if declared["narrowing"]:
40
+ lines.append("note: " + "; ".join(declared["narrowing"]) + " (narrows what a pass covers)")
41
+ if result.trace:
42
+ lines.append(f"counterexample ({len(result.trace)} states):")
43
+ lines.append(tla.render_trace(result.trace))
44
+ elif result.verdict == "error":
45
+ lines.append(result.output.strip()[-1500:])
46
+ if result.verdict == "pass" and result.distinct_states <= 1:
47
+ # A spec that reaches one state checks nothing; treat it as a spec bug.
48
+ return Outcome("fail", "spec reaches at most one state; invariants are vacuous", lines)
49
+ return Outcome(result.verdict, result.reason, lines)
50
+
51
+
52
+ class LeanChecker(Checker):
53
+ kind = "lean"
54
+ summary = "Build a Lean project and audit its theorems' axioms"
55
+
56
+ def specs(self, check: Check) -> list[str]:
57
+ project = check.require("project").rstrip("/")
58
+ return [f"{project}/**/*.lean", f"{project}/lakefile.*", f"{project}/lean-toolchain"]
59
+
60
+ def run(self, ctx: Context, check: Check) -> Outcome:
61
+ result = lean.run(
62
+ ctx.root / check.require("project"),
63
+ check.get_list("theorems"),
64
+ timeout=check.get_int("timeout", 1800),
65
+ )
66
+ lines = list(result.findings) + [f"WEAK {w}" for w in result.warnings]
67
+ for theorem, axioms in result.axioms.items():
68
+ lines.append(f"{theorem}: axioms [{', '.join(axioms) or 'none'}]")
69
+ if result.verdict != "pass" and not result.findings:
70
+ lines.append(result.output.strip()[-1500:])
71
+ return Outcome(result.verdict, result.reason, lines)
72
+
73
+
74
+ def _resolve_tools(command: str) -> str:
75
+ """Point a leading `lake` at the elan install, which is often not on PATH."""
76
+ parts = shlex.split(command)
77
+ if parts and parts[0] == "lake":
78
+ parts[0] = lean.find_lake()
79
+ return shlex.join(parts)
80
+
81
+
82
+ class DRTChecker(Checker):
83
+ kind = "drt"
84
+ summary = "Differentially test the implementation against an executable model"
85
+
86
+ def run(self, ctx: Context, check: Check) -> Outcome:
87
+ cases, min_len, max_len = check.get_int("cases", 1000), check.get_int("min_len", 0), check.get_int("max_len", 8)
88
+ if cases < 1:
89
+ raise ConfigError(f"check {check.id!r}: `cases` must be at least 1; zero inputs prove nothing")
90
+ if min_len > max_len:
91
+ raise ConfigError(f"check {check.id!r}: `min_len` ({min_len}) is greater than `max_len` ({max_len})")
92
+ model = drt.Side(_resolve_tools(check.require("model")), ctx.root / check.get("model_cwd", "."))
93
+ impl = drt.Side(_resolve_tools(check.require("impl")), ctx.root / check.get("impl_cwd", "."))
94
+ result = drt.run(
95
+ model,
96
+ impl,
97
+ alphabet=[str(t) for t in check.require("alphabet")],
98
+ cases=cases,
99
+ min_len=min_len,
100
+ max_len=max_len,
101
+ seed=check.get_int("seed", 0),
102
+ timeout=check.get_int("timeout", 600),
103
+ )
104
+ lines = []
105
+ if result.minimal:
106
+ m = result.minimal
107
+ lines += [
108
+ f"minimal diverging input: {m.input or '(empty)'}",
109
+ f" model: {m.model}",
110
+ f" impl: {m.impl}",
111
+ ]
112
+ return Outcome(result.verdict, result.reason, lines)
rung/checks/presets.py ADDED
@@ -0,0 +1,96 @@
1
+ """Built-in scanner presets for `kind = "scan"` checks.
2
+
3
+ Each preset is a command that writes SARIF, plus sensible defaults for which
4
+ files it covers and which severity blocks. `{out}` in a command is replaced with
5
+ a temporary file path; a command without `{out}` must write SARIF to stdout.
6
+
7
+ Commands marked `verified` were run against real output while writing Rung.
8
+ Any preset can be overridden field by field in `rung.toml`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Preset:
18
+ name: str
19
+ purpose: str
20
+ run: str
21
+ install: str
22
+ fail_on: str = "warning"
23
+ # Severity for results the tool reports without a level (gitleaks reports none).
24
+ default_level: str = "warning"
25
+ # Days a verdict stays valid even if no file changes: advisory databases update daily.
26
+ max_age_days: int | None = None
27
+ covers: list[str] = field(default_factory=lambda: ["**/*"])
28
+ verified: bool = False
29
+
30
+
31
+ PRESETS: dict[str, Preset] = {
32
+ p.name: p
33
+ for p in (
34
+ Preset(
35
+ "gitleaks",
36
+ "secrets committed to the code",
37
+ # --redact keeps secret values out of the report, the ledger and the terminal.
38
+ "gitleaks dir . --no-banner --redact -f sarif -r {out}",
39
+ "brew install gitleaks | https://github.com/gitleaks/gitleaks",
40
+ default_level="error",
41
+ verified=True,
42
+ ),
43
+ Preset(
44
+ "osv-scanner",
45
+ "dependencies with known vulnerabilities",
46
+ "osv-scanner scan source -r . -f sarif --output-file {out}",
47
+ "brew install osv-scanner | https://github.com/google/osv-scanner",
48
+ max_age_days=1,
49
+ covers=[
50
+ "**/requirements*.txt",
51
+ "**/pyproject.toml",
52
+ "**/poetry.lock",
53
+ "**/uv.lock",
54
+ "**/Pipfile.lock",
55
+ "**/package.json",
56
+ "**/package-lock.json",
57
+ "**/pnpm-lock.yaml",
58
+ "**/yarn.lock",
59
+ "**/go.mod",
60
+ "**/go.sum",
61
+ "**/Cargo.lock",
62
+ "**/Gemfile.lock",
63
+ "**/composer.lock",
64
+ "**/pom.xml",
65
+ "**/gradle.lockfile",
66
+ ],
67
+ verified=True,
68
+ ),
69
+ Preset(
70
+ "zizmor",
71
+ "insecure GitHub Actions workflows",
72
+ "zizmor --offline --format sarif .",
73
+ "brew install zizmor | https://github.com/zizmorcore/zizmor",
74
+ covers=[".github/workflows/**", ".github/actions/**", "action.yml", "action.yaml"],
75
+ verified=True,
76
+ ),
77
+ Preset(
78
+ "semgrep",
79
+ "insecure code patterns (injection, SSRF, unsafe deserialization)",
80
+ "semgrep scan --config p/default --sarif --metrics off --quiet --output {out}",
81
+ "pipx install semgrep | https://semgrep.dev",
82
+ ),
83
+ Preset(
84
+ "opengrep",
85
+ "insecure code patterns, with the open Semgrep fork",
86
+ "opengrep scan --config p/default --sarif --quiet --output {out}",
87
+ "https://github.com/opengrep/opengrep",
88
+ ),
89
+ Preset(
90
+ "trivy",
91
+ "vulnerabilities and misconfigurations in containers and infrastructure code",
92
+ "trivy fs --scanners vuln,misconfig,secret --format sarif --output {out} .",
93
+ "brew install trivy | https://trivy.dev",
94
+ ),
95
+ )
96
+ }