Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ machine-readable answer key per target, and a control panel to drive it all.
> enforces it. Read [SECURITY.md](SECURITY.md) before first run, including the
> part about what container isolation does not cover.

[![The limeyard control panel, listing every target with its kind, state, address and upstream](https://raw.githubusercontent.com/clickswave/limeyard/main/docs/panel.png)](#control-panel)

The control panel on http://127.0.0.1:7000, showing the lab as it runs.

limeyard was `vuln_apps`. It was renamed because it stopped being a folder of
applications: it now holds bare services, a DNS zone, a WAF pair, a precision
target and APK fixtures, none of which are apps.
Expand Down
2 changes: 1 addition & 1 deletion control/limed/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ def _post(self, p):
if tr:
truth[slug] = tr
card = scorer.score(findings, truth, tool=body.get("tool", "unknown"),
only=body.get("targets"))
only=body.get("targets"), cost=body.get("cost"))
if body.get("save"):
path = scorer.save(card, lime.TRUTH_DIR)
card["saved"] = path
Expand Down
6 changes: 6 additions & 0 deletions control/limed/lime.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ def containers(t):


def state_of(t):
# A fixture has no containers by design: an APK is installed on a device,
# not started here. Calling that "stopped" makes a healthy lab look like
# something is down, and made the CLI contradict both the panel and
# doctor, which have always called it what it is.
if is_fixture(t):
return "fixture", "d"
cs = containers(t)
if not cs:
return "stopped", "d"
Expand Down
49 changes: 47 additions & 2 deletions control/limed/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,18 @@ def entry_matches(entry, finding):
return flags


def score(findings, truth, tool="unknown", only=None, scopes=("black-box", "authed")):
"""findings: [{target, class, path, method?, param?, in?, severity?}]"""
def score(findings, truth, tool="unknown", only=None, scopes=("black-box", "authed"),
cost=None):
"""findings: [{target, class, path, method?, param?, in?, severity?}]

`cost` is what the run spent, per target:
{target: {seconds, requests, truncated}}. A target marked `truncated` had
its pass cut by a deadline, so its misses are not evidence about the tool.
It is scored because scan time is a product quality attribute and nothing
was measuring it: a pass against a 45-endpoint application quietly grew to
69 minutes, and the scorecard that recorded 43 of 48 said nothing about it.
A scanner nobody can afford to run is not accurate, it is theoretical.
"""
per_target, totals = {}, {"expected": 0, "detected": 0, "missed": 0,
"false_positive": 0, "unmatched": 0, "out_of_scope": 0}
by_class = {}
Expand Down Expand Up @@ -279,6 +289,41 @@ def score(findings, truth, tool="unknown", only=None, scopes=("black-box", "auth
for c, b in by_class.items():
b["recall"] = round(b["detected"] / b["expected"], 4) if b["expected"] else None

# Cost, alongside correctness. Reported per target and in total, with the
# slowest named: an average hides the one target that took an hour.
cost = cost or {}
if cost:
secs = {t: float(v.get("seconds") or 0) for t, v in cost.items()}
reqs = {t: int(v.get("requests") or 0) for t, v in cost.items()}
slowest = max(secs, key=secs.get) if secs else None
totals["seconds"] = round(sum(secs.values()), 1)
totals["requests"] = sum(reqs.values())
totals["slowest_target"] = slowest
totals["slowest_seconds"] = round(secs.get(slowest, 0), 1) if slowest else None
for t, v in cost.items():
if t in per_target:
per_target[t]["seconds"] = round(float(v.get("seconds") or 0), 1)
per_target[t]["requests"] = int(v.get("requests") or 0)
# Sites the engine could not get a baseline for. They were not
# tested, and a recall figure that counts them as clean misses is
# measuring the load the pass applied, not the tool.
if v.get("skipped_sites"):
per_target[t]["skipped_sites"] = int(v["skipped_sites"])
# Where the time went, per class. The ratio is what tells a
# slow scan from an expensive one.
if v.get("by_class"):
per_target[t]["by_class"] = str(v["by_class"])
if v.get("truncated"):
per_target[t]["truncated"] = True
# A run the clock cut short did not miss what it never reached, and a
# recall figure that does not say so is worse than no figure: it turns
# "we ran out of time" into "the scanner cannot find this". Named at
# the top of the card so it cannot be read past.
cut = sorted(t for t, v in cost.items() if v.get("truncated"))
if cut:
totals["truncated_targets"] = cut
totals["recall_is_a_lower_bound"] = True

return {
"tool": tool,
"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ services:
# ...and see the definitions at their real host path, so relative paths
# inside a target's compose resolve correctly through the socket.
- ${LIMEYARD_DIR:-.}:${LIMEYARD_DIR:-/work}
# ...and run the control plane from the working tree rather than the copy
# baked into the image. Without this an edit to lime.py, api.py or
# scorer.py does nothing at all until someone remembers to rebuild, and
# the symptom is the worst kind: the code you just changed runs exactly as
# it did before, silently. Cost an hour of confusion on 2026-09-11.
- ./control/limed:/opt/limed:ro
working_dir: ${LIMEYARD_DIR:-/work}
ports:
- "127.0.0.1:7099:7099"
Expand Down
Binary file added docs/panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading