Skip to content

Commit 9569b75

Browse files
committed
RedMesh 0.13.0: report contract hardening, CSRF finding identity, typed finding contract on read (RM-062, RM-117, RM-118) (#526)
What changed: - RM-062: archived findings are read through flat_finding_from_dict (read_archived_findings, wired into PassReport.from_dict and JobArchive.from_dict). A foreign or future schema_version raises; unstamped v0 findings stay readable. New end-to-end test: triage follows its finding across the passes of one job. - RM-117: CSRF findings carry AffectedAsset(url=path, parameter=normalised form action, method=POST), so there is one finding per page + action. - Version: ver.py 2.10.472, RedMesh backend 0.13.0. - RM-118: new authenticated_action finding field (performed | attempted | not_permitted | not_applicable), stamped by the credential probes and validated; probe_display_name is stamped in the flat walk; deterministic report-findings fixture built from real probe output, shared byte-for-byte with Navigator and the hub. Why: - Client-rerun report fixes. The report must state what happened after a login from a field, not parsed text; CSRF forms on one page must not collide; and the archive read path must refuse a schema it does not understand. Checks: - full red_mesh suite: 6194 passed, 5 skipped - git diff --check: clean
1 parent 81bc42b commit 9569b75

20 files changed

Lines changed: 2164 additions & 20 deletions

‎extensions/business/cybersec/red_mesh/constants.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ def resolve_target_response_timeout(timeout_profile, standard_timeout):
342342
# The backend release, recorded in every JobConfig at launch so a report names
343343
# the scanner that produced it. `pentester_api_01.__VER__` must equal it (a test
344344
# guards the pair; the plugin loader reads the literal there).
345-
REDMESH_BACKEND_VERSION = "0.12.0"
345+
REDMESH_BACKEND_VERSION = "0.13.0"
346346

347347
# =====================================================================
348348
# Risk score computation

‎extensions/business/cybersec/red_mesh/findings.py‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ class Finding:
180180
# `enrich_finding_for_probe`; the report labels the latter as policy instead
181181
# of printing a bare badge (RM-086 item 5).
182182
severity_source: str = ""
183+
# What happened after an accepted login, for findings that record one
184+
# (`models.finding_schema.AUTHENTICATED_ACTIONS`); "" otherwise. Not identity
185+
# and not content: `finding_identity` reads neither (RM-118).
186+
authenticated_action: str = ""
183187

184188
# Metadata
185189
ai_generated: bool = False # P12 invariant — must stay False for finding data
@@ -267,14 +271,15 @@ def compute_dedup_key(
267271
normalised asset and classification from that payload, and ignores
268272
`description` and `severity`.
269273
270-
It does **not** yet ignore the title, and the difference matters here. No
271-
blackbox probe sets `affected_assets`, so every `Finding` reaching this
272-
method falls to `dedup_key`'s last-resort branch, which folds in the
273-
lowercased title to keep two genuinely different findings from one probe
274-
from colliding. So rewording a *description* preserves the key — the defect
275-
B2 exists to fix — while rewording a *title* still forks it. RM-061 owns
276-
giving blackbox findings a url and parameter; until it lands, this is as
277-
stable as identity can honestly be.
274+
It does **not** yet ignore the title, and the difference matters here.
275+
Almost no blackbox probe sets `affected_assets` (the CSRF probe is the
276+
exception, RM-117), so most `Finding`s reaching this method fall to
277+
`dedup_key`'s last-resort branch, which folds in the lowercased title to
278+
keep two genuinely different findings from one probe from colliding. So
279+
rewording a *description* preserves the key — the defect B2 exists to fix —
280+
while rewording a *title* still forks it. RM-061 owns giving the remaining
281+
blackbox findings a url and parameter; until it lands, this is as stable as
282+
identity can honestly be.
278283
"""
279284
return _dedup_key(
280285
self._identity_payload(probe_id), asset_canonical=asset_canonical,

‎extensions/business/cybersec/red_mesh/mixins/risk.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@
3131
)
3232

3333

34+
def _probe_display_name(probe_id):
35+
"""The registered `display_name` for a probe id, or "" when it has none.
36+
37+
Importing `worker` is what registers the probes (`@register_probe` runs at
38+
import), so a host that never imported a worker still resolves names.
39+
Graybox probes are not in this registry and keep their id in the report.
40+
"""
41+
try:
42+
from .. import worker as _worker # noqa: F401 (registers the probes)
43+
from ..worker.probe_registry import get_probe_metadata
44+
metadata = get_probe_metadata(probe_id)
45+
except Exception:
46+
return ""
47+
return getattr(metadata, "display_name", "") or ""
48+
49+
3450
def normalize_risk_score(raw_total):
3551
"""
3652
Map a raw risk total onto 0-100 without saturating.
@@ -202,6 +218,11 @@ def normalize_flat_finding(finding, port, protocol, probe_name, category):
202218
# had, reading a raw severity string where `Finding.compute_signature`
203219
# read `Severity.value`.
204220
item["probe"] = probe_name
221+
# What the report prints for the probe (RM-118). Not identity and not
222+
# content: `finding_identity` reads neither.
223+
display_name = _probe_display_name(probe_name)
224+
if display_name:
225+
item["probe_display_name"] = display_name
205226
# The probe-time signature wins over anything computed here.
206227
#
207228
# This walk runs on opposite sides of `_redact_report` depending on the

‎extensions/business/cybersec/red_mesh/models/archive.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass, asdict, field
1414

1515
from extensions.business.cybersec.red_mesh.models.shared import _strip_none
16+
from extensions.business.cybersec.red_mesh.models.finding_schema import read_archived_findings
1617
from ..tenancy.execution import (
1718
ExecutionBinding, binding_from_record, binding_value, copy_bound_config, checked_archive_config,
1819
)
@@ -407,7 +408,7 @@ def from_dict(cls, d: dict) -> PassReport:
407408
quick_summary=d.get("quick_summary"),
408409
llm_failed=d.get("llm_failed"),
409410
llm_report_sections=d.get("llm_report_sections"),
410-
findings=d.get("findings"),
411+
findings=read_archived_findings(d.get("findings")),
411412
scan_metrics=d.get("scan_metrics"),
412413
worker_scan_metrics=d.get("worker_scan_metrics"),
413414
redmesh_test_attestation=d.get("redmesh_test_attestation"),
@@ -471,6 +472,13 @@ def from_dict(cls, d: dict) -> UiAggregate:
471472
)
472473

473474

475+
def _read_archived_pass(pass_dict):
476+
"""Gate an archived pass's findings without reshaping the pass (RM-062)."""
477+
if not isinstance(pass_dict, dict) or "findings" not in pass_dict:
478+
return pass_dict
479+
return {**pass_dict, "findings": read_archived_findings(pass_dict["findings"])}
480+
481+
474482
@dataclass(frozen=True)
475483
class JobArchive:
476484
"""
@@ -522,7 +530,7 @@ def from_dict(cls, d: dict) -> JobArchive:
522530
job_id=d["job_id"],
523531
job_config=d.get("job_config", {}),
524532
timeline=d.get("timeline", []),
525-
passes=d.get("passes", []),
533+
passes=[_read_archived_pass(p) for p in d.get("passes", [])],
526534
ui_aggregate=d.get("ui_aggregate", {}),
527535
duration=d.get("duration", 0),
528536
date_created=d.get("date_created", 0),

‎extensions/business/cybersec/red_mesh/models/finding_identity.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ def dedup_key(finding: dict, *, asset_canonical: str | None = None) -> str:
229229
neither a scenario id nor a specific location. Blackbox probes do not yet
230230
attach url/parameter — RM-061 owns that — so for them the title is the only
231231
thing separating "reflected XSS in search" from "stored XSS in comment": same
232-
port, same probe, same CWE, same synthesised `{host, port}` asset. Dropping it
232+
port, same probe, same CWE, same synthesised `{host, port}` asset. (The CSRF
233+
probe already sets page + form action, RM-117.) Dropping it
233234
unconditionally would merge two real findings into one, which is a worse
234235
failure than the wording-fork it avoids. Findings that do carry a location or
235236
a scenario id — every graybox finding, and every blackbox finding once RM-061

‎extensions/business/cybersec/red_mesh/models/finding_schema.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@
3131
# half-confidence finding with nothing anywhere saying it was not understood.
3232
FINDING_CONFIDENCES = ("certain", "firm", "tentative")
3333

34+
# What happened after an accepted login (RM-118). The report read this from
35+
# prose — a `; authenticated action:` marker in evidence and title phrases — so a
36+
# probe rewording flipped a client-facing line with every test green. ""
37+
# means the finding records no login.
38+
AUTHENTICATED_ACTION_PERFORMED = "performed" # the RoE-gated action ran and returned output
39+
AUTHENTICATED_ACTION_ATTEMPTED = "attempted" # permitted; tried or ran, completed nothing
40+
AUTHENTICATED_ACTION_NOT_PERMITTED = "not_permitted" # the RoE withheld it
41+
AUTHENTICATED_ACTION_NOT_APPLICABLE = "not_applicable" # no action exists for the service
42+
AUTHENTICATED_ACTIONS = (
43+
AUTHENTICATED_ACTION_PERFORMED,
44+
AUTHENTICATED_ACTION_ATTEMPTED,
45+
AUTHENTICATED_ACTION_NOT_PERMITTED,
46+
AUTHENTICATED_ACTION_NOT_APPLICABLE,
47+
)
48+
3449
REQUIRED_FINDING_FIELDS = (
3550
"finding_id",
3651
"title",
@@ -73,6 +88,8 @@
7388
# opened, so the caveat the letter promised was recorded and not shown.
7489
"severity_source",
7590
"backport_status",
91+
# Read by name for §3.5.2 (RM-118).
92+
"authenticated_action",
7693
"affected_assets",
7794
"evidence_items",
7895
"evidence_artifacts",
@@ -149,6 +166,7 @@ class FlatFinding:
149166
status: str = ""
150167
severity_source: str = ""
151168
backport_status: str = ""
169+
authenticated_action: str = ""
152170
# Verbatim carrier for anything the contract does not name. Not a dumping
153171
# ground: it is what makes "the contract does not know this field" different
154172
# from "this field did not exist", which is the distinction the whitelists
@@ -214,6 +232,9 @@ def validate_flat_finding(payload: Any) -> list[str]:
214232
errors.append(f"severity is invalid: {payload['severity']}")
215233
if "confidence" in payload and payload["confidence"] not in FINDING_CONFIDENCES:
216234
errors.append(f"confidence is invalid: {payload['confidence']}")
235+
action = payload.get("authenticated_action", "")
236+
if action and action not in AUTHENTICATED_ACTIONS:
237+
errors.append(f"authenticated_action is invalid: {action}")
217238

218239
return errors
219240

@@ -251,3 +272,20 @@ def flat_finding_from_dict(payload: dict) -> FlatFinding:
251272

252273
extra = {key: value for key, value in payload.items() if key not in _KNOWN_FIELDS}
253274
return FlatFinding(**known, extra=extra, _present=present)
275+
276+
277+
def read_archived_findings(findings: Any) -> Any:
278+
"""Read an archived findings list through the contract (RM-062).
279+
280+
Each finding goes through `flat_finding_from_dict`, so a schema or version
281+
this build does not understand raises instead of being read under the wrong
282+
assumptions. The round trip loses nothing, and the source key order is kept so
283+
the stored content does not change. `None` (no findings key) passes through.
284+
"""
285+
if findings is None:
286+
return None
287+
out = []
288+
for payload in findings:
289+
restored = flat_finding_from_dict(payload).to_dict()
290+
out.append({key: restored[key] for key in payload})
291+
return out

‎extensions/business/cybersec/red_mesh/pentester_api_01.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ def _run_manual_analysis_worker(work: _ManualAnalysisWork) -> _ManualAnalysisOut
572572
return _ManualAnalysisWorker(work).run()
573573

574574

575-
__VER__ = '0.12.0'
575+
__VER__ = '0.13.0'
576576

577577

578578
_CONFIG = {

0 commit comments

Comments
 (0)