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.
- rung/__init__.py +3 -0
- rung/__main__.py +3 -0
- rung/agents/__init__.py +15 -0
- rung/agents/hooks.py +66 -0
- rung/agents/install.py +120 -0
- rung/analysis/__init__.py +1 -0
- rung/analysis/plan.py +165 -0
- rung/analysis/report.py +125 -0
- rung/analysis/survey.py +319 -0
- rung/checks/__init__.py +1 -0
- rung/checks/base.py +59 -0
- rung/checks/command.py +122 -0
- rung/checks/deps.py +230 -0
- rung/checks/findings.py +153 -0
- rung/checks/formal.py +112 -0
- rung/checks/presets.py +96 -0
- rung/checks/prove.py +125 -0
- rung/checks/registry.py +35 -0
- rung/checks/runner.py +161 -0
- rung/checks/scan.py +152 -0
- rung/checks/shortcuts.py +109 -0
- rung/cli/__init__.py +157 -0
- rung/cli/commands/__init__.py +13 -0
- rung/cli/commands/common.py +41 -0
- rung/cli/commands/draft.py +214 -0
- rung/cli/commands/manage.py +112 -0
- rung/cli/commands/run.py +114 -0
- rung/cli/commands/setup.py +174 -0
- rung/cli/output.py +54 -0
- rung/core/__init__.py +1 -0
- rung/core/config.py +278 -0
- rung/core/errors.py +9 -0
- rung/core/git.py +236 -0
- rung/core/laws.py +124 -0
- rung/core/ledger.py +225 -0
- rung/core/proc.py +58 -0
- rung/core/redact.py +36 -0
- rung/drafting/__init__.py +1 -0
- rung/drafting/common.py +86 -0
- rung/drafting/lean.py +117 -0
- rung/drafting/property.py +339 -0
- rung/drafting/providers.py +294 -0
- rung/drafting/runtime.py +186 -0
- rung/drafting/sandbox.py +63 -0
- rung/drafting/tla.py +97 -0
- rung/engines/__init__.py +1 -0
- rung/engines/drt.py +123 -0
- rung/engines/lean.py +209 -0
- rung/engines/mutate.py +138 -0
- rung/engines/sarif.py +223 -0
- rung/engines/shortcuts.py +426 -0
- rung/engines/tla.py +199 -0
- rung/guides/__init__.py +20 -0
- rung/guides/lean.md +64 -0
- rung/guides/property.md +48 -0
- rung/guides/rules.md +15 -0
- rung/guides/runtime.md +94 -0
- rung/guides/tla.md +58 -0
- rung/guides/verify.md +14 -0
- rung_cli-0.1.2.dist-info/METADATA +296 -0
- rung_cli-0.1.2.dist-info/RECORD +64 -0
- rung_cli-0.1.2.dist-info/WHEEL +4 -0
- rung_cli-0.1.2.dist-info/entry_points.txt +2 -0
- 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)
|
rung/checks/findings.py
ADDED
|
@@ -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
|
+
}
|